pycentauri 0.1.1__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.1.1 → pycentauri-0.2.0}/CHANGELOG.md +21 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/PKG-INFO +42 -4
- {pycentauri-0.1.1 → pycentauri-0.2.0}/README.md +32 -3
- {pycentauri-0.1.1 → pycentauri-0.2.0}/pyproject.toml +8 -1
- {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/__init__.py +1 -1
- {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/cli.py +32 -0
- pycentauri-0.2.0/src/pycentauri/server.py +396 -0
- pycentauri-0.2.0/tests/test_server.py +106 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/.github/workflows/ci.yml +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/.github/workflows/publish.yml +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/.gitignore +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/CONTRIBUTING.md +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/LICENSE +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/examples/snapshot.py +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/examples/start_print.py +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/examples/status_watch.py +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/camera.py +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/client.py +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/discovery.py +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/mcp/__init__.py +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/mcp/__main__.py +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/mcp/server.py +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/models.py +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/py.typed +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/sdcp.py +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/tests/__init__.py +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/tests/integration/__init__.py +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/tests/test_client.py +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/tests/test_discovery.py +0 -0
- {pycentauri-0.1.1 → pycentauri-0.2.0}/tests/test_sdcp.py +0 -0
|
@@ -6,6 +6,27 @@ Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
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
|
+
|
|
9
30
|
## [0.1.1] - 2026-04-22
|
|
10
31
|
|
|
11
32
|
### Fixed
|
|
@@ -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:
|
|
@@ -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:
|
|
@@ -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
|
|
@@ -336,6 +336,38 @@ def cmd_print_stop(host: HostOpt = None, enable_control: ControlOpt = False) ->
|
|
|
336
336
|
_run(run())
|
|
337
337
|
|
|
338
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
|
+
|
|
339
371
|
@app.command("mcp")
|
|
340
372
|
def cmd_mcp(
|
|
341
373
|
enable_control: ControlOpt = False,
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""FastAPI HTTP server exposing the printer over REST + SSE.
|
|
2
|
+
|
|
3
|
+
A single long-lived :class:`~pycentauri.client.Printer` connection is held for
|
|
4
|
+
the lifetime of the server and reused across requests. That keeps us
|
|
5
|
+
well under the Elegoo firmware's 5-WebSocket slot limit and means
|
|
6
|
+
``GET /status`` returns the in-memory cached push rather than opening a
|
|
7
|
+
fresh connection on every request.
|
|
8
|
+
|
|
9
|
+
On WebSocket errors we reconnect in the background with exponential
|
|
10
|
+
backoff. The mainboard ID learned from the first successful discovery is
|
|
11
|
+
cached so reconnects don't have to wait on an Attributes push.
|
|
12
|
+
|
|
13
|
+
Surfaces (register with ``centauri server``):
|
|
14
|
+
|
|
15
|
+
* ``GET /`` — health and version info
|
|
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 when the
|
|
22
|
+
server is launched with ``--enable-control``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import contextlib
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
from collections.abc import AsyncIterator
|
|
32
|
+
from contextlib import asynccontextmanager
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
36
|
+
from fastapi.responses import JSONResponse, Response
|
|
37
|
+
from pydantic import BaseModel, Field
|
|
38
|
+
from sse_starlette.sse import EventSourceResponse
|
|
39
|
+
|
|
40
|
+
from pycentauri import __version__
|
|
41
|
+
from pycentauri.client import ControlDisabledError, Printer, PrinterError
|
|
42
|
+
from pycentauri.discovery import DiscoveredPrinter
|
|
43
|
+
from pycentauri.discovery import discover as lan_discover
|
|
44
|
+
|
|
45
|
+
log = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
RECONNECT_BACKOFF_START = 1.0
|
|
48
|
+
RECONNECT_BACKOFF_MAX = 30.0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PrinterManager:
|
|
52
|
+
"""Owns a single long-lived :class:`Printer` connection for the whole server.
|
|
53
|
+
|
|
54
|
+
Runs a background supervisor that keeps a connection open, reconnects
|
|
55
|
+
with exponential backoff on failure, and re-subscribes to status pushes
|
|
56
|
+
after each reconnect. The Printer instance is exposed via :attr:`printer`
|
|
57
|
+
for tool use — callers that need write operations should pass
|
|
58
|
+
``enable_control=True`` at construction.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
host: str,
|
|
64
|
+
*,
|
|
65
|
+
enable_control: bool = False,
|
|
66
|
+
mainboard_id: str | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.host = host
|
|
69
|
+
self.enable_control = enable_control
|
|
70
|
+
self._mainboard_id = mainboard_id
|
|
71
|
+
self._printer: Printer | None = None
|
|
72
|
+
self._supervisor: asyncio.Task[None] | None = None
|
|
73
|
+
self._ready = asyncio.Event()
|
|
74
|
+
self._closing = False
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def printer(self) -> Printer:
|
|
78
|
+
"""The currently active Printer. Raises if not connected."""
|
|
79
|
+
if self._printer is None or self._printer._closed:
|
|
80
|
+
raise HTTPException(status_code=503, detail="printer connection not ready")
|
|
81
|
+
return self._printer
|
|
82
|
+
|
|
83
|
+
async def start(self) -> None:
|
|
84
|
+
"""Launch the supervisor task and wait for the first connection."""
|
|
85
|
+
self._supervisor = asyncio.create_task(self._run(), name="pycentauri-supervisor")
|
|
86
|
+
# Don't block startup indefinitely; server is still useful even while
|
|
87
|
+
# reconnecting (endpoints will 503 cleanly).
|
|
88
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
89
|
+
await asyncio.wait_for(self._ready.wait(), timeout=10.0)
|
|
90
|
+
|
|
91
|
+
async def stop(self) -> None:
|
|
92
|
+
self._closing = True
|
|
93
|
+
if self._supervisor is not None and not self._supervisor.done():
|
|
94
|
+
self._supervisor.cancel()
|
|
95
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
96
|
+
await self._supervisor
|
|
97
|
+
if self._printer is not None:
|
|
98
|
+
with contextlib.suppress(Exception):
|
|
99
|
+
await self._printer.close()
|
|
100
|
+
|
|
101
|
+
async def _run(self) -> None:
|
|
102
|
+
backoff = RECONNECT_BACKOFF_START
|
|
103
|
+
while not self._closing:
|
|
104
|
+
# Learn the mainboard via discovery if we don't have one yet.
|
|
105
|
+
if self._mainboard_id is None:
|
|
106
|
+
found = await lan_discover(timeout=1.5, retries=2)
|
|
107
|
+
for p in found:
|
|
108
|
+
if p.host == self.host and p.mainboard_id:
|
|
109
|
+
self._mainboard_id = p.mainboard_id
|
|
110
|
+
log.info("learned mainboard id %s", self._mainboard_id)
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
self._printer = await Printer.connect(
|
|
115
|
+
self.host,
|
|
116
|
+
enable_control=self.enable_control,
|
|
117
|
+
mainboard_id=self._mainboard_id,
|
|
118
|
+
)
|
|
119
|
+
# Prime the subscription so status pushes start flowing.
|
|
120
|
+
with contextlib.suppress(Exception):
|
|
121
|
+
await asyncio.wait_for(
|
|
122
|
+
self._printer._ensure_subscribed(),
|
|
123
|
+
timeout=5.0,
|
|
124
|
+
)
|
|
125
|
+
log.info("connected to %s", self.host)
|
|
126
|
+
self._ready.set()
|
|
127
|
+
backoff = RECONNECT_BACKOFF_START
|
|
128
|
+
# Hold until the reader dies (disconnect).
|
|
129
|
+
reader = self._printer._reader
|
|
130
|
+
if reader is not None:
|
|
131
|
+
await reader
|
|
132
|
+
except Exception as e:
|
|
133
|
+
log.warning("connection to %s failed: %r", self.host, e)
|
|
134
|
+
|
|
135
|
+
if self._closing:
|
|
136
|
+
break
|
|
137
|
+
self._ready.clear()
|
|
138
|
+
await asyncio.sleep(backoff)
|
|
139
|
+
backoff = min(backoff * 2, RECONNECT_BACKOFF_MAX)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# --- Pydantic request bodies ------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class StartPrintBody(BaseModel):
|
|
146
|
+
filename: str = Field(..., description="File name as it appears on the printer.")
|
|
147
|
+
storage: str = Field("local", description="'local' or 'udisk'.")
|
|
148
|
+
auto_leveling: bool = True
|
|
149
|
+
timelapse: bool = False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# --- Dependency helpers -----------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_manager(request: Request) -> PrinterManager:
|
|
156
|
+
mgr: PrinterManager | None = getattr(request.app.state, "manager", None)
|
|
157
|
+
if mgr is None:
|
|
158
|
+
raise HTTPException(status_code=503, detail="printer manager not initialised")
|
|
159
|
+
return mgr
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def require_control(manager: PrinterManager = Depends(get_manager)) -> PrinterManager:
|
|
163
|
+
if not manager.enable_control:
|
|
164
|
+
raise HTTPException(
|
|
165
|
+
status_code=403,
|
|
166
|
+
detail=(
|
|
167
|
+
"control actions are disabled. Launch the server with "
|
|
168
|
+
"--enable-control to enable POST /print/* endpoints."
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
return manager
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --- App factory ------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def create_app(
|
|
178
|
+
host: str,
|
|
179
|
+
*,
|
|
180
|
+
enable_control: bool = False,
|
|
181
|
+
mainboard_id: str | None = None,
|
|
182
|
+
) -> FastAPI:
|
|
183
|
+
"""Build the FastAPI app. ``host`` is the printer's IP/hostname.
|
|
184
|
+
|
|
185
|
+
The app runs a single background :class:`PrinterManager` that owns the
|
|
186
|
+
WebSocket lifecycle. Control endpoints are registered only when
|
|
187
|
+
``enable_control`` is ``True``.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
@asynccontextmanager
|
|
191
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
192
|
+
manager = PrinterManager(host, enable_control=enable_control, mainboard_id=mainboard_id)
|
|
193
|
+
app.state.manager = manager
|
|
194
|
+
await manager.start()
|
|
195
|
+
try:
|
|
196
|
+
yield
|
|
197
|
+
finally:
|
|
198
|
+
await manager.stop()
|
|
199
|
+
|
|
200
|
+
app = FastAPI(
|
|
201
|
+
title="pycentauri HTTP API",
|
|
202
|
+
version=__version__,
|
|
203
|
+
description=(
|
|
204
|
+
"HTTP + SSE surface for an Elegoo Centauri Carbon 3D printer. "
|
|
205
|
+
"Wrapping the same client library that powers the `centauri` "
|
|
206
|
+
"CLI and the MCP server."
|
|
207
|
+
),
|
|
208
|
+
lifespan=lifespan,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
@app.get("/", tags=["meta"])
|
|
212
|
+
async def root(manager: PrinterManager = Depends(get_manager)) -> dict[str, Any]:
|
|
213
|
+
return {
|
|
214
|
+
"service": "pycentauri",
|
|
215
|
+
"version": __version__,
|
|
216
|
+
"printer_host": manager.host,
|
|
217
|
+
"mainboard_id": manager._mainboard_id,
|
|
218
|
+
"connected": manager._printer is not None and not manager._printer._closed,
|
|
219
|
+
"enable_control": manager.enable_control,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
@app.get("/status", tags=["read"])
|
|
223
|
+
async def status_endpoint(
|
|
224
|
+
manager: PrinterManager = Depends(get_manager),
|
|
225
|
+
) -> dict[str, Any]:
|
|
226
|
+
try:
|
|
227
|
+
st = await asyncio.wait_for(manager.printer.status(), timeout=10)
|
|
228
|
+
except asyncio.TimeoutError as err:
|
|
229
|
+
raise HTTPException(status_code=504, detail="printer status timeout") from err
|
|
230
|
+
except PrinterError as err:
|
|
231
|
+
raise HTTPException(status_code=502, detail=str(err)) from err
|
|
232
|
+
return {
|
|
233
|
+
"state": st.state,
|
|
234
|
+
"print_status": st.print_status,
|
|
235
|
+
"progress": st.progress,
|
|
236
|
+
"filename": st.filename,
|
|
237
|
+
"layer": {
|
|
238
|
+
"current": st.print_info.current_layer if st.print_info else None,
|
|
239
|
+
"total": st.print_info.total_layer if st.print_info else None,
|
|
240
|
+
},
|
|
241
|
+
"temperatures": {
|
|
242
|
+
"nozzle": {"actual": st.temp_nozzle, "target": st.temp_nozzle_target},
|
|
243
|
+
"bed": {"actual": st.temp_bed, "target": st.temp_bed_target},
|
|
244
|
+
"chamber": {"actual": st.temp_chamber, "target": st.temp_chamber_target},
|
|
245
|
+
},
|
|
246
|
+
"position": st.coord,
|
|
247
|
+
"z_offset": st.z_offset,
|
|
248
|
+
"fans": st.fan_speed,
|
|
249
|
+
"raw": st.raw,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
@app.get("/attributes", tags=["read"])
|
|
253
|
+
async def attributes_endpoint(
|
|
254
|
+
manager: PrinterManager = Depends(get_manager),
|
|
255
|
+
) -> dict[str, Any]:
|
|
256
|
+
try:
|
|
257
|
+
attrs = await asyncio.wait_for(manager.printer.attributes(), timeout=10)
|
|
258
|
+
except asyncio.TimeoutError as err:
|
|
259
|
+
raise HTTPException(status_code=504, detail="printer attributes timeout") from err
|
|
260
|
+
except PrinterError as err:
|
|
261
|
+
raise HTTPException(status_code=502, detail=str(err)) from err
|
|
262
|
+
return {
|
|
263
|
+
"mainboard_id": attrs.mainboard_id,
|
|
264
|
+
"name": attrs.name,
|
|
265
|
+
"machine_name": attrs.machine_name,
|
|
266
|
+
"firmware_version": attrs.firmware_version,
|
|
267
|
+
"capabilities": attrs.capabilities,
|
|
268
|
+
"raw": attrs.raw,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
@app.get("/snapshot", tags=["read"], response_class=Response)
|
|
272
|
+
async def snapshot_endpoint(
|
|
273
|
+
manager: PrinterManager = Depends(get_manager),
|
|
274
|
+
) -> Response:
|
|
275
|
+
try:
|
|
276
|
+
jpeg = await asyncio.wait_for(manager.printer.snapshot(), timeout=15)
|
|
277
|
+
except Exception as err:
|
|
278
|
+
raise HTTPException(status_code=502, detail=f"snapshot failed: {err}") from err
|
|
279
|
+
return Response(
|
|
280
|
+
content=jpeg,
|
|
281
|
+
media_type="image/jpeg",
|
|
282
|
+
headers={"Cache-Control": "no-store"},
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
@app.get("/discover", tags=["read"])
|
|
286
|
+
async def discover_endpoint() -> list[dict[str, Any]]:
|
|
287
|
+
found: list[DiscoveredPrinter] = await lan_discover(timeout=2.0, retries=2)
|
|
288
|
+
return [
|
|
289
|
+
{
|
|
290
|
+
"host": p.host,
|
|
291
|
+
"mainboard_id": p.mainboard_id,
|
|
292
|
+
"name": p.name,
|
|
293
|
+
"machine_name": p.machine_name,
|
|
294
|
+
"firmware_version": p.firmware_version,
|
|
295
|
+
}
|
|
296
|
+
for p in found
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
@app.get("/events/status", tags=["read"])
|
|
300
|
+
async def status_stream(
|
|
301
|
+
request: Request, manager: PrinterManager = Depends(get_manager)
|
|
302
|
+
) -> EventSourceResponse:
|
|
303
|
+
"""Server-Sent Events stream of live status pushes.
|
|
304
|
+
|
|
305
|
+
Clients subscribe once and receive one ``data:`` line per push.
|
|
306
|
+
Disconnects are handled silently on the server side.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
async def gen() -> AsyncIterator[dict[str, str]]:
|
|
310
|
+
try:
|
|
311
|
+
async for st in manager.printer.watch():
|
|
312
|
+
if await request.is_disconnected():
|
|
313
|
+
break
|
|
314
|
+
yield {"event": "status", "data": json.dumps(st.raw, default=str)}
|
|
315
|
+
except PrinterError as err:
|
|
316
|
+
yield {"event": "error", "data": str(err)}
|
|
317
|
+
|
|
318
|
+
return EventSourceResponse(gen())
|
|
319
|
+
|
|
320
|
+
if not enable_control:
|
|
321
|
+
return app
|
|
322
|
+
|
|
323
|
+
# --- Control endpoints (registered only when --enable-control) ----------
|
|
324
|
+
|
|
325
|
+
@app.post("/print/start", tags=["control"])
|
|
326
|
+
async def start_print(
|
|
327
|
+
body: StartPrintBody, manager: PrinterManager = Depends(require_control)
|
|
328
|
+
) -> dict[str, Any]:
|
|
329
|
+
try:
|
|
330
|
+
result = await manager.printer.start_print(
|
|
331
|
+
body.filename,
|
|
332
|
+
storage=body.storage,
|
|
333
|
+
auto_leveling=body.auto_leveling,
|
|
334
|
+
timelapse=body.timelapse,
|
|
335
|
+
)
|
|
336
|
+
except ControlDisabledError as err:
|
|
337
|
+
raise HTTPException(status_code=403, detail=str(err)) from err
|
|
338
|
+
except PrinterError as err:
|
|
339
|
+
raise HTTPException(status_code=502, detail=str(err)) from err
|
|
340
|
+
return {"ok": True, "response": result.inner}
|
|
341
|
+
|
|
342
|
+
@app.post("/print/pause", tags=["control"])
|
|
343
|
+
async def pause_print(
|
|
344
|
+
manager: PrinterManager = Depends(require_control),
|
|
345
|
+
) -> dict[str, Any]:
|
|
346
|
+
try:
|
|
347
|
+
result = await manager.printer.pause()
|
|
348
|
+
except PrinterError as err:
|
|
349
|
+
raise HTTPException(status_code=502, detail=str(err)) from err
|
|
350
|
+
return {"ok": True, "response": result.inner}
|
|
351
|
+
|
|
352
|
+
@app.post("/print/resume", tags=["control"])
|
|
353
|
+
async def resume_print(
|
|
354
|
+
manager: PrinterManager = Depends(require_control),
|
|
355
|
+
) -> dict[str, Any]:
|
|
356
|
+
try:
|
|
357
|
+
result = await manager.printer.resume()
|
|
358
|
+
except PrinterError as err:
|
|
359
|
+
raise HTTPException(status_code=502, detail=str(err)) from err
|
|
360
|
+
return {"ok": True, "response": result.inner}
|
|
361
|
+
|
|
362
|
+
@app.post("/print/stop", tags=["control"])
|
|
363
|
+
async def stop_print(
|
|
364
|
+
manager: PrinterManager = Depends(require_control),
|
|
365
|
+
) -> dict[str, Any]:
|
|
366
|
+
try:
|
|
367
|
+
result = await manager.printer.stop()
|
|
368
|
+
except PrinterError as err:
|
|
369
|
+
raise HTTPException(status_code=502, detail=str(err)) from err
|
|
370
|
+
return {"ok": True, "response": result.inner}
|
|
371
|
+
|
|
372
|
+
return app
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def run(
|
|
376
|
+
host: str,
|
|
377
|
+
*,
|
|
378
|
+
bind: str = "127.0.0.1",
|
|
379
|
+
port: int = 8787,
|
|
380
|
+
enable_control: bool = False,
|
|
381
|
+
mainboard_id: str | None = None,
|
|
382
|
+
log_level: str = "info",
|
|
383
|
+
) -> None:
|
|
384
|
+
"""Launch the server with uvicorn (blocks).
|
|
385
|
+
|
|
386
|
+
Defaults bind to loopback. Set ``bind="0.0.0.0"`` to expose on the LAN —
|
|
387
|
+
in that case put an authenticating reverse proxy in front, since the
|
|
388
|
+
HTTP surface itself is unauthenticated in v0.2.
|
|
389
|
+
"""
|
|
390
|
+
import uvicorn
|
|
391
|
+
|
|
392
|
+
app = create_app(host, enable_control=enable_control, mainboard_id=mainboard_id)
|
|
393
|
+
uvicorn.run(app, host=bind, port=port, log_level=log_level)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
__all__ = ["JSONResponse", "PrinterManager", "create_app", "run"]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Tests for the FastAPI HTTP server.
|
|
2
|
+
|
|
3
|
+
Uses the existing ``_FakePrinter`` fixture from ``test_client`` to stand up
|
|
4
|
+
a local SDCP WebSocket server, then spins up the FastAPI app against it.
|
|
5
|
+
We hit the app through httpx's ASGI transport — no real HTTP port bound.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
pytest.importorskip("fastapi")
|
|
16
|
+
|
|
17
|
+
from pycentauri import server as server_module
|
|
18
|
+
from tests.test_client import MAINBOARD, _FakePrinter
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def _asgi_client(app: Any) -> httpx.AsyncClient:
|
|
22
|
+
transport = httpx.ASGITransport(app=app)
|
|
23
|
+
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def test_read_endpoints(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
27
|
+
server = _FakePrinter()
|
|
28
|
+
await server.start()
|
|
29
|
+
monkeypatch.setattr("pycentauri.client.WS_PORT", server.port)
|
|
30
|
+
|
|
31
|
+
app = server_module.create_app("127.0.0.1", mainboard_id=MAINBOARD)
|
|
32
|
+
# Mimic the lifespan manager manually: the async-context machinery
|
|
33
|
+
# under httpx.AsyncClient doesn't drive lifespan, so do it ourselves.
|
|
34
|
+
async with app.router.lifespan_context(app), await _asgi_client(app) as client:
|
|
35
|
+
r = await client.get("/")
|
|
36
|
+
assert r.status_code == 200
|
|
37
|
+
body = r.json()
|
|
38
|
+
assert body["service"] == "pycentauri"
|
|
39
|
+
assert body["printer_host"] == "127.0.0.1"
|
|
40
|
+
|
|
41
|
+
r = await client.get("/status")
|
|
42
|
+
assert r.status_code == 200
|
|
43
|
+
s = r.json()
|
|
44
|
+
assert s["print_status"] == 13
|
|
45
|
+
assert s["temperatures"]["nozzle"]["actual"] == 210.0
|
|
46
|
+
|
|
47
|
+
r = await client.get("/attributes")
|
|
48
|
+
assert r.status_code == 200
|
|
49
|
+
assert r.json()["mainboard_id"] == MAINBOARD
|
|
50
|
+
|
|
51
|
+
await server.stop()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def test_control_endpoints_404_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
55
|
+
server = _FakePrinter()
|
|
56
|
+
await server.start()
|
|
57
|
+
monkeypatch.setattr("pycentauri.client.WS_PORT", server.port)
|
|
58
|
+
|
|
59
|
+
app = server_module.create_app("127.0.0.1", mainboard_id=MAINBOARD)
|
|
60
|
+
async with app.router.lifespan_context(app), await _asgi_client(app) as client:
|
|
61
|
+
for path in ("/print/start", "/print/pause", "/print/resume", "/print/stop"):
|
|
62
|
+
r = await client.post(
|
|
63
|
+
path, json={"filename": "x.gcode"} if path.endswith("start") else {}
|
|
64
|
+
)
|
|
65
|
+
assert r.status_code == 404, (
|
|
66
|
+
f"control endpoint {path} should not exist without enable_control"
|
|
67
|
+
)
|
|
68
|
+
await server.stop()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def test_control_endpoints_registered_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
72
|
+
server = _FakePrinter()
|
|
73
|
+
await server.start()
|
|
74
|
+
monkeypatch.setattr("pycentauri.client.WS_PORT", server.port)
|
|
75
|
+
|
|
76
|
+
app = server_module.create_app("127.0.0.1", enable_control=True, mainboard_id=MAINBOARD)
|
|
77
|
+
async with app.router.lifespan_context(app), await _asgi_client(app) as client:
|
|
78
|
+
r = await client.post("/print/pause")
|
|
79
|
+
assert r.status_code == 200
|
|
80
|
+
assert r.json()["ok"] is True
|
|
81
|
+
|
|
82
|
+
r = await client.post("/print/resume")
|
|
83
|
+
assert r.status_code == 200
|
|
84
|
+
|
|
85
|
+
# The fake printer recorded the Cmd.PAUSE_PRINT + Cmd.RESUME_PRINT commands.
|
|
86
|
+
from pycentauri.sdcp import Cmd
|
|
87
|
+
|
|
88
|
+
cmds = [m["Data"]["Cmd"] for m in server.received]
|
|
89
|
+
assert int(Cmd.PAUSE_PRINT) in cmds
|
|
90
|
+
assert int(Cmd.RESUME_PRINT) in cmds
|
|
91
|
+
await server.stop()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def test_start_print_request_body_validation(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
95
|
+
server = _FakePrinter()
|
|
96
|
+
await server.start()
|
|
97
|
+
monkeypatch.setattr("pycentauri.client.WS_PORT", server.port)
|
|
98
|
+
|
|
99
|
+
app = server_module.create_app("127.0.0.1", enable_control=True, mainboard_id=MAINBOARD)
|
|
100
|
+
async with app.router.lifespan_context(app), await _asgi_client(app) as client:
|
|
101
|
+
r = await client.post("/print/start", json={})
|
|
102
|
+
assert r.status_code == 422 # filename is required
|
|
103
|
+
|
|
104
|
+
r = await client.post("/print/start", json={"filename": "cube.gcode"})
|
|
105
|
+
assert r.status_code == 200
|
|
106
|
+
await server.stop()
|
|
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
|
|
File without changes
|