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.
Files changed (32) hide show
  1. pycentauri-0.2.0/CHANGELOG.md +71 -0
  2. {pycentauri-0.1.0 → pycentauri-0.2.0}/PKG-INFO +57 -4
  3. {pycentauri-0.1.0 → pycentauri-0.2.0}/README.md +47 -3
  4. {pycentauri-0.1.0 → pycentauri-0.2.0}/pyproject.toml +8 -1
  5. {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/__init__.py +1 -1
  6. {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/cli.py +63 -19
  7. {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/client.py +38 -15
  8. {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/discovery.py +15 -5
  9. {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/mcp/server.py +32 -14
  10. pycentauri-0.2.0/src/pycentauri/server.py +396 -0
  11. {pycentauri-0.1.0 → pycentauri-0.2.0}/tests/test_client.py +51 -0
  12. pycentauri-0.2.0/tests/test_discovery.py +63 -0
  13. pycentauri-0.2.0/tests/test_server.py +106 -0
  14. pycentauri-0.1.0/CHANGELOG.md +0 -21
  15. pycentauri-0.1.0/tests/test_discovery.py +0 -63
  16. {pycentauri-0.1.0 → pycentauri-0.2.0}/.github/workflows/ci.yml +0 -0
  17. {pycentauri-0.1.0 → pycentauri-0.2.0}/.github/workflows/publish.yml +0 -0
  18. {pycentauri-0.1.0 → pycentauri-0.2.0}/.gitignore +0 -0
  19. {pycentauri-0.1.0 → pycentauri-0.2.0}/CONTRIBUTING.md +0 -0
  20. {pycentauri-0.1.0 → pycentauri-0.2.0}/LICENSE +0 -0
  21. {pycentauri-0.1.0 → pycentauri-0.2.0}/examples/snapshot.py +0 -0
  22. {pycentauri-0.1.0 → pycentauri-0.2.0}/examples/start_print.py +0 -0
  23. {pycentauri-0.1.0 → pycentauri-0.2.0}/examples/status_watch.py +0 -0
  24. {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/camera.py +0 -0
  25. {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/mcp/__init__.py +0 -0
  26. {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/mcp/__main__.py +0 -0
  27. {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/models.py +0 -0
  28. {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/py.typed +0 -0
  29. {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/sdcp.py +0 -0
  30. {pycentauri-0.1.0 → pycentauri-0.2.0}/tests/__init__.py +0 -0
  31. {pycentauri-0.1.0 → pycentauri-0.2.0}/tests/integration/__init__.py +0 -0
  32. {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.1.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 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:
@@ -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 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:
@@ -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.1.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
@@ -15,4 +15,4 @@ __all__ = [
15
15
  "discover",
16
16
  ]
17
17
 
18
- __version__ = "0.1.0"
18
+ __version__ = "0.2.0"
@@ -55,9 +55,21 @@ def _echo_err(msg: str) -> None:
55
55
  typer.echo(msg, err=True)
56
56
 
57
57
 
58
- async def _resolve_host(host: str | None) -> str:
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
- return host
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 _resolve_host(host)
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 _resolve_host(host)
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 _resolve_host(host)
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 _resolve_host(host)
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 _resolve_host(host)
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 _resolve_host(host)
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 _resolve_host(host)
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 _resolve_host(host)
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
- self = cls(host, enable_control=enable_control, push_period_ms=push_period_ms)
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
- # The printer sends an Attributes push shortly after connect; a fresh
132
- # GET_PRINTER_ATTRIBUTES also triggers one. Fire one off just in case.
133
- with contextlib.suppress(Exception):
134
- await self._send_raw(
135
- sdcp.encode(
136
- sdcp.build_request(
137
- sdcp.Cmd.GET_PRINTER_ATTRIBUTES, None, self._mainboard_id or ""
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. Safe to call concurrently from multiple
76
- tasks; each call uses its own UDP socket.
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
- transport.sendto(DISCOVERY_PROBE, (broadcast_address, port))
92
- await asyncio.sleep(timeout)
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