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.
Files changed (30) hide show
  1. {pycentauri-0.1.1 → pycentauri-0.2.0}/CHANGELOG.md +21 -0
  2. {pycentauri-0.1.1 → pycentauri-0.2.0}/PKG-INFO +42 -4
  3. {pycentauri-0.1.1 → pycentauri-0.2.0}/README.md +32 -3
  4. {pycentauri-0.1.1 → pycentauri-0.2.0}/pyproject.toml +8 -1
  5. {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/__init__.py +1 -1
  6. {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/cli.py +32 -0
  7. pycentauri-0.2.0/src/pycentauri/server.py +396 -0
  8. pycentauri-0.2.0/tests/test_server.py +106 -0
  9. {pycentauri-0.1.1 → pycentauri-0.2.0}/.github/workflows/ci.yml +0 -0
  10. {pycentauri-0.1.1 → pycentauri-0.2.0}/.github/workflows/publish.yml +0 -0
  11. {pycentauri-0.1.1 → pycentauri-0.2.0}/.gitignore +0 -0
  12. {pycentauri-0.1.1 → pycentauri-0.2.0}/CONTRIBUTING.md +0 -0
  13. {pycentauri-0.1.1 → pycentauri-0.2.0}/LICENSE +0 -0
  14. {pycentauri-0.1.1 → pycentauri-0.2.0}/examples/snapshot.py +0 -0
  15. {pycentauri-0.1.1 → pycentauri-0.2.0}/examples/start_print.py +0 -0
  16. {pycentauri-0.1.1 → pycentauri-0.2.0}/examples/status_watch.py +0 -0
  17. {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/camera.py +0 -0
  18. {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/client.py +0 -0
  19. {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/discovery.py +0 -0
  20. {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/mcp/__init__.py +0 -0
  21. {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/mcp/__main__.py +0 -0
  22. {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/mcp/server.py +0 -0
  23. {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/models.py +0 -0
  24. {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/py.typed +0 -0
  25. {pycentauri-0.1.1 → pycentauri-0.2.0}/src/pycentauri/sdcp.py +0 -0
  26. {pycentauri-0.1.1 → pycentauri-0.2.0}/tests/__init__.py +0 -0
  27. {pycentauri-0.1.1 → pycentauri-0.2.0}/tests/integration/__init__.py +0 -0
  28. {pycentauri-0.1.1 → pycentauri-0.2.0}/tests/test_client.py +0 -0
  29. {pycentauri-0.1.1 → pycentauri-0.2.0}/tests/test_discovery.py +0 -0
  30. {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.1.1
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 three surfaces:
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 # library + CLI
63
- pip install "pycentauri[mcp]" # + MCP server dependencies
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 three surfaces:
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 # library + CLI
24
- pip install "pycentauri[mcp]" # + MCP server dependencies
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.1.1"
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
@@ -15,4 +15,4 @@ __all__ = [
15
15
  "discover",
16
16
  ]
17
17
 
18
- __version__ = "0.1.1"
18
+ __version__ = "0.2.0"
@@ -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