tracerlab 0.1.5__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.
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ *.pyc
6
+ .DS_Store
7
+ dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Atharv Santosh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.5
2
+ Name: tracerlab
3
+ Version: 0.1.5
4
+ Summary: MCP server for Cisco Packet Tracer 9. Build, configure and verify networks from an AI agent.
5
+ Project-URL: Homepage, https://github.com/atharv-santosh/tracerlab
6
+ Project-URL: Repository, https://github.com/atharv-santosh/tracerlab
7
+ Project-URL: Issues, https://github.com/atharv-santosh/tracerlab/issues
8
+ Author-email: Atharv Santosh <atharvsantosh1@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: automation,ccna,cisco,ios,mcp,networking,packet-tracer
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Education
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Education
21
+ Classifier: Topic :: System :: Networking
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: mcp<3,>=2.3
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Tracerlab
27
+
28
+ MCP server for Cisco Packet Tracer 9. Lets Claude Code (or any MCP client) build networks in a running Packet Tracer: add devices, cable them, push IOS configs, address everything, set up routing, ping, and find what's broken. It can also read and write .pkt files without Packet Tracer open.
29
+
30
+ ![Building a 3 router OSPF ring, cabling it, addressing it and pinging across it](https://raw.githubusercontent.com/atharv-santosh/tracerlab/main/docs/demo.gif)
31
+
32
+ Twelve devices, twelve cables, OSPF across the ring and 6/6 pings, from one prompt.
33
+
34
+ There are also some ready-made labs (router on a stick, OSPF/EIGRP/RIP topologies, NAT, HSRP, EtherChannel, IPv6) that build in one call.
35
+
36
+ ## Install
37
+
38
+ Needs [uv](https://docs.astral.sh/uv/) and Packet Tracer 9.
39
+
40
+ Register the server with your MCP client:
41
+
42
+ ```
43
+ claude mcp add packet-tracer -- uvx --from tracerlab packet-tracer-mcp
44
+ ```
45
+
46
+ Cursor (`~/.cursor/mcp.json`), VS Code (`.vscode/mcp.json`) and Codex take the same command:
47
+
48
+ ```json
49
+ {
50
+ "mcpServers": {
51
+ "packet-tracer": { "command": "uvx", "args": ["--from", "tracerlab", "packet-tracer-mcp"] }
52
+ }
53
+ }
54
+ ```
55
+
56
+ Then install the Packet Tracer side once:
57
+
58
+ ```
59
+ uvx tracerlab install-extension
60
+ ```
61
+
62
+ Restart Packet Tracer and answer Yes when it asks about the new script module. A small "PT-MCP Bridge"
63
+ window shows up and goes green once the MCP server is running.
64
+
65
+ If something isn't connecting, run `uvx tracerlab doctor`.
66
+
67
+ ## Use
68
+
69
+ Open Packet Tracer, start Claude Code and ask for things like:
70
+
71
+ - build a 3 router OSPF ring with a PC on each LAN and ping everything
72
+ - set up router on a stick with VLANs 10, 20, 30 and DHCP
73
+ - what's wrong with this network
74
+ - show me the running config on R1
75
+
76
+ `examples/enterprise-two-site.pkt` is a bigger network built entirely through the tools.
77
+
78
+ ## Tests
79
+
80
+ ```
81
+ uv run pytest
82
+ ```
@@ -0,0 +1,57 @@
1
+ # Tracerlab
2
+
3
+ MCP server for Cisco Packet Tracer 9. Lets Claude Code (or any MCP client) build networks in a running Packet Tracer: add devices, cable them, push IOS configs, address everything, set up routing, ping, and find what's broken. It can also read and write .pkt files without Packet Tracer open.
4
+
5
+ ![Building a 3 router OSPF ring, cabling it, addressing it and pinging across it](https://raw.githubusercontent.com/atharv-santosh/tracerlab/main/docs/demo.gif)
6
+
7
+ Twelve devices, twelve cables, OSPF across the ring and 6/6 pings, from one prompt.
8
+
9
+ There are also some ready-made labs (router on a stick, OSPF/EIGRP/RIP topologies, NAT, HSRP, EtherChannel, IPv6) that build in one call.
10
+
11
+ ## Install
12
+
13
+ Needs [uv](https://docs.astral.sh/uv/) and Packet Tracer 9.
14
+
15
+ Register the server with your MCP client:
16
+
17
+ ```
18
+ claude mcp add packet-tracer -- uvx --from tracerlab packet-tracer-mcp
19
+ ```
20
+
21
+ Cursor (`~/.cursor/mcp.json`), VS Code (`.vscode/mcp.json`) and Codex take the same command:
22
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "packet-tracer": { "command": "uvx", "args": ["--from", "tracerlab", "packet-tracer-mcp"] }
27
+ }
28
+ }
29
+ ```
30
+
31
+ Then install the Packet Tracer side once:
32
+
33
+ ```
34
+ uvx tracerlab install-extension
35
+ ```
36
+
37
+ Restart Packet Tracer and answer Yes when it asks about the new script module. A small "PT-MCP Bridge"
38
+ window shows up and goes green once the MCP server is running.
39
+
40
+ If something isn't connecting, run `uvx tracerlab doctor`.
41
+
42
+ ## Use
43
+
44
+ Open Packet Tracer, start Claude Code and ask for things like:
45
+
46
+ - build a 3 router OSPF ring with a PC on each LAN and ping everything
47
+ - set up router on a stick with VLANs 10, 20, 30 and DHCP
48
+ - what's wrong with this network
49
+ - show me the running config on R1
50
+
51
+ `examples/enterprise-two-site.pkt` is a bigger network built entirely through the tools.
52
+
53
+ ## Tests
54
+
55
+ ```
56
+ uv run pytest
57
+ ```
@@ -0,0 +1,57 @@
1
+ # Tracerlab
2
+
3
+ MCP server for Cisco Packet Tracer 9. Lets Claude Code (or any MCP client) build networks in a running Packet Tracer: add devices, cable them, push IOS configs, address everything, set up routing, ping, and find what's broken. It can also read and write .pkt files without Packet Tracer open.
4
+
5
+ ![Building a 3 router OSPF ring, cabling it, addressing it and pinging across it](https://raw.githubusercontent.com/atharv-santosh/tracerlab/main/docs/demo.gif)
6
+
7
+ Twelve devices, twelve cables, OSPF across the ring and 6/6 pings, from one prompt.
8
+
9
+ There are also some ready-made labs (router on a stick, OSPF/EIGRP/RIP topologies, NAT, HSRP, EtherChannel, IPv6) that build in one call.
10
+
11
+ ## Install
12
+
13
+ Needs [uv](https://docs.astral.sh/uv/) and Packet Tracer 9.
14
+
15
+ Register the server with your MCP client:
16
+
17
+ ```
18
+ claude mcp add packet-tracer -- uvx --from tracerlab packet-tracer-mcp
19
+ ```
20
+
21
+ Cursor (`~/.cursor/mcp.json`), VS Code (`.vscode/mcp.json`) and Codex take the same command:
22
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "packet-tracer": { "command": "uvx", "args": ["--from", "tracerlab", "packet-tracer-mcp"] }
27
+ }
28
+ }
29
+ ```
30
+
31
+ Then install the Packet Tracer side once:
32
+
33
+ ```
34
+ uvx tracerlab install-extension
35
+ ```
36
+
37
+ Restart Packet Tracer and answer Yes when it asks about the new script module. A small "PT-MCP Bridge"
38
+ window shows up and goes green once the MCP server is running.
39
+
40
+ If something isn't connecting, run `uvx tracerlab doctor`.
41
+
42
+ ## Use
43
+
44
+ Open Packet Tracer, start Claude Code and ask for things like:
45
+
46
+ - build a 3 router OSPF ring with a PC on each LAN and ping everything
47
+ - set up router on a stick with VLANs 10, 20, 30 and DHCP
48
+ - what's wrong with this network
49
+ - show me the running config on R1
50
+
51
+ `examples/enterprise-two-site.pkt` is a bigger network built entirely through the tools.
52
+
53
+ ## Tests
54
+
55
+ ```
56
+ uv run pytest
57
+ ```
@@ -0,0 +1,52 @@
1
+ [project]
2
+ name = "tracerlab"
3
+ version = "0.1.5"
4
+ description = "MCP server for Cisco Packet Tracer 9. Build, configure and verify networks from an AI agent."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Atharv Santosh", email = "atharvsantosh1@gmail.com" }]
9
+ keywords = ["mcp", "packet-tracer", "cisco", "networking", "ccna", "ios", "automation"]
10
+ dependencies = ["mcp>=2.3,<3"]
11
+
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Education",
15
+ "Intended Audience :: System Administrators",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Topic :: Education",
22
+ "Topic :: System :: Networking",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/atharv-santosh/tracerlab"
27
+ Repository = "https://github.com/atharv-santosh/tracerlab"
28
+ Issues = "https://github.com/atharv-santosh/tracerlab/issues"
29
+
30
+ [project.scripts]
31
+ tracerlab = "tracerlab.cli:main"
32
+ packet-tracer-mcp = "tracerlab.server:main"
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "pytest>=8",
37
+ "pytest-asyncio>=0.23",
38
+ ]
39
+
40
+ [build-system]
41
+ requires = ["hatchling"]
42
+ build-backend = "hatchling.build"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/tracerlab"]
46
+
47
+ [tool.hatch.build.targets.sdist]
48
+ include = ["src/tracerlab", "tests", "README.md", "LICENSE"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+ asyncio_mode = "auto"
@@ -0,0 +1,3 @@
1
+ """MCP server for Cisco Packet Tracer 9."""
2
+
3
+ __version__ = "0.1.5"
@@ -0,0 +1,359 @@
1
+ """HTTP long-poll bridge to the PT script module.
2
+
3
+ If another tracerlab already owns the port, jobs are relayed through it (POST /exec).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import itertools
10
+ import json
11
+ import logging
12
+ import os
13
+ import time
14
+ import urllib.error
15
+ import urllib.request
16
+ from dataclasses import dataclass, field
17
+ from importlib import resources
18
+ from typing import Any
19
+
20
+ log = logging.getLogger("tracerlab.bridge")
21
+
22
+ HOST = "127.0.0.1"
23
+ PORT = int(os.environ.get("TRACERLAB_PORT") or os.environ.get("PTMCP_PORT") or "39150")
24
+ POLL_HOLD_S = 20.0 # how long a /poll request is held open when idle
25
+ STALE_AFTER_S = POLL_HOLD_S + 10.0
26
+ PICKUP_S = 8.0 # a queued job must be picked up by a poll within this
27
+ MAX_BODY = 64 * 1024 * 1024
28
+
29
+
30
+ class PTError(RuntimeError):
31
+ """Raised for errors reported by Packet Tracer or the bridge."""
32
+
33
+
34
+ class NotConnected(PTError):
35
+ pass
36
+
37
+
38
+ @dataclass
39
+ class _Job:
40
+ id: int
41
+ code: str
42
+ future: asyncio.Future
43
+ picked: asyncio.Event = field(default_factory=asyncio.Event)
44
+ created: float = field(default_factory=time.monotonic)
45
+
46
+
47
+ def _bridge_js() -> str:
48
+ return resources.files("tracerlab").joinpath("js/bridge.js").read_text()
49
+
50
+
51
+ def _bridge_build() -> str:
52
+ import hashlib
53
+ return hashlib.sha1(_bridge_js().encode()).hexdigest()[:10]
54
+
55
+
56
+ def _runtime_js() -> str:
57
+ return resources.files("tracerlab").joinpath("js/runtime.js").read_text()
58
+
59
+
60
+ def _runtime_version(js: str) -> str:
61
+ # include the hash so edits to runtime.js reload without a version bump
62
+ import hashlib
63
+ from . import __version__
64
+ return f"{__version__}+{hashlib.sha1(js.encode()).hexdigest()[:8]}"
65
+
66
+
67
+ class Bridge:
68
+ def __init__(self, host: str = HOST, port: int = PORT) -> None:
69
+ self.host, self.port = host, port
70
+ self._queue: asyncio.Queue[_Job] = asyncio.Queue()
71
+ self._inflight: dict[int, _Job] = {}
72
+ self._ids = itertools.count(1)
73
+ self._chunks: dict[int, dict[int, str]] = {}
74
+ self._server: asyncio.base_events.Server | None = None
75
+ self._last_seen = 0.0
76
+ self._pt_info: dict[str, Any] = {}
77
+ self._runtime_loaded_for: str | None = None # session token of the PT side
78
+ self._runtime_lock = asyncio.Lock()
79
+ self.mode = "stopped" # "owner" | "relay" | "stopped"
80
+
81
+ async def start(self) -> None:
82
+ # created here so they bind to the running loop
83
+ self._queue = asyncio.Queue()
84
+ self._inflight = {}
85
+ self._chunks: dict[int, dict[int, str]] = {}
86
+ self._runtime_lock = asyncio.Lock()
87
+ try:
88
+ self._server = await asyncio.start_server(self._handle, self.host, self.port)
89
+ self.mode = "owner"
90
+ log.info("bridge listening on http://%s:%d", self.host, self.port)
91
+ except OSError:
92
+ if await asyncio.to_thread(self._probe_owner):
93
+ self.mode = "relay"
94
+ log.info("port %d owned by another ptmcp; relaying", self.port)
95
+ else:
96
+ raise PTError(f"port {self.port} is in use by something that is not ptmcp "
97
+ "(set PTMCP_PORT to another port and update the PT module)")
98
+
99
+ async def stop(self) -> None:
100
+ self._last_seen = 0.0
101
+ if self._server:
102
+ self._server.close()
103
+ await self._server.wait_closed()
104
+ for job in list(self._inflight.values()):
105
+ if not job.future.done():
106
+ job.future.set_exception(PTError("bridge stopped"))
107
+
108
+ def _probe_owner(self) -> bool:
109
+ try:
110
+ with urllib.request.urlopen(f"http://{self.host}:{self.port}/ping", timeout=2) as r:
111
+ return json.loads(r.read()).get("service") == "ptmcp"
112
+ except Exception:
113
+ return False
114
+
115
+ @property
116
+ def connected(self) -> bool:
117
+ return time.monotonic() - self._last_seen < STALE_AFTER_S
118
+
119
+ def status(self) -> dict[str, Any]:
120
+ if self.mode == "relay":
121
+ try:
122
+ with urllib.request.urlopen(f"http://{self.host}:{self.port}/ping", timeout=2) as r:
123
+ d = json.loads(r.read())
124
+ d["mode"] = "relay"
125
+ return d
126
+ except Exception as e: # pragma: no cover - network edge
127
+ return {"mode": "relay", "connected": False, "error": str(e)}
128
+ return {
129
+ "service": "ptmcp",
130
+ "mode": self.mode,
131
+ "connected": self.connected,
132
+ "seconds_since_poll": round(time.monotonic() - self._last_seen, 1) if self._last_seen else None,
133
+ "pt": self._pt_info,
134
+ "queued": self._queue.qsize(),
135
+ "inflight": len(self._inflight),
136
+ }
137
+
138
+ async def run(self, code: str, timeout: float = 60.0, *, raw: bool = False) -> Any:
139
+ """Run a JS function body in PT (PTM in scope) and return its JSON value."""
140
+ if not raw:
141
+ await self._ensure_runtime(timeout)
142
+ return await self._submit(code, timeout)
143
+
144
+ async def wait_connected(self, timeout: float) -> bool:
145
+ end = time.monotonic() + timeout
146
+ while time.monotonic() < end:
147
+ if self.mode == "relay" or self.connected:
148
+ return True
149
+ await asyncio.sleep(0.25)
150
+ return False
151
+
152
+ async def _ensure_runtime(self, timeout: float) -> None:
153
+ async with self._runtime_lock:
154
+ probe = "return (typeof PTM !== 'undefined' && PTM.VERSION) ? PTM.VERSION : null;"
155
+ v = await self._submit(probe, timeout)
156
+ js = _runtime_js()
157
+ want = _runtime_version(js)
158
+ if v != want:
159
+ await self._submit(js + f"\nPTM.VERSION = {json.dumps(want)};\nreturn PTM.VERSION;", timeout)
160
+
161
+ async def _submit(self, code: str, timeout: float) -> Any:
162
+ if self.mode == "relay":
163
+ return await asyncio.to_thread(self._relay, code, timeout)
164
+ if not self.connected:
165
+ raise NotConnected(
166
+ "Packet Tracer is not connected. Open Packet Tracer 9 with the PT-MCP script "
167
+ "module installed (Extensions > PT-MCP Bridge shows a green 'connected' pill). "
168
+ "See pt_setup_instructions.")
169
+ loop = asyncio.get_running_loop()
170
+ job = _Job(next(self._ids), code, loop.create_future())
171
+ self._inflight[job.id] = job
172
+ await self._queue.put(job)
173
+ try:
174
+ await asyncio.wait_for(job.picked.wait(), PICKUP_S)
175
+ except asyncio.TimeoutError:
176
+ self._inflight.pop(job.id, None)
177
+ job.future.cancel()
178
+ self._last_seen = 0.0
179
+ raise NotConnected("Packet Tracer stopped polling (PT closed, bridge window closed, or a modal "
180
+ "dialog is blocking PT). Check the 'PT-MCP Bridge' window.") from None
181
+ try:
182
+ env = await asyncio.wait_for(job.future, timeout)
183
+ except asyncio.TimeoutError:
184
+ raise PTError(f"Packet Tracer did not answer within {timeout:.0f}s "
185
+ "(a modal dialog open in PT blocks scripting)") from None
186
+ finally:
187
+ self._inflight.pop(job.id, None)
188
+ return _unwrap(env)
189
+
190
+ def _relay(self, code: str, timeout: float) -> Any:
191
+ req = urllib.request.Request(
192
+ f"http://{self.host}:{self.port}/exec",
193
+ data=json.dumps({"code": code, "timeout": timeout}).encode(),
194
+ headers={"Content-Type": "application/json"}, method="POST")
195
+ try:
196
+ with urllib.request.urlopen(req, timeout=timeout + 5) as r:
197
+ env = json.loads(r.read())
198
+ except urllib.error.HTTPError as e:
199
+ env = json.loads(e.read() or b"{}")
200
+ if env.get("bridge_error"):
201
+ raise (NotConnected if env.get("not_connected") else PTError)(env["bridge_error"])
202
+ return _unwrap(env)
203
+
204
+ async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
205
+ try:
206
+ head = await reader.readuntil(b"\r\n\r\n")
207
+ lines = head.decode("latin-1").split("\r\n")
208
+ method, target, _ = lines[0].split(" ", 2)
209
+ headers = {k.strip().lower(): v.strip() for k, v in
210
+ (l.split(":", 1) for l in lines[1:] if ":" in l)}
211
+ n = int(headers.get("content-length", "0") or 0)
212
+ if n > MAX_BODY:
213
+ return await self._send(writer, 413, {"error": "too large"})
214
+ if "chunked" in headers.get("transfer-encoding", "").lower():
215
+ body = await _read_chunked(reader)
216
+ else:
217
+ body = await reader.readexactly(n) if n else b""
218
+ path = target.split("?", 1)[0]
219
+ if method == "OPTIONS":
220
+ return await self._send(writer, 204, None)
221
+ if path == "/ping":
222
+ return await self._send(writer, 200, self.status())
223
+ if path == "/hello":
224
+ from urllib.parse import parse_qs
225
+ q = parse_qs(target.split("?", 1)[1]) if "?" in target else {}
226
+ self._pt_info["webview"] = {k: v[0] for k, v in q.items()}
227
+ log.info("PT web view hello: %s", self._pt_info["webview"])
228
+ return await self._send(writer, 200, {"ok": True})
229
+ if path == "/poll":
230
+ return await self._poll(writer, target)
231
+ if path == "/result" and method == "POST":
232
+ self._last_seen = time.monotonic()
233
+ self._on_result(json.loads(body))
234
+ return await self._send(writer, 200, {"ok": True})
235
+ if path == "/exec" and method == "POST":
236
+ return await self._exec_for_relay(writer, json.loads(body))
237
+ if path == "/r": # chunked GET result from the PT web view
238
+ self._last_seen = time.monotonic()
239
+ self._on_chunk(target)
240
+ return await self._send_raw(writer, 200, "application/javascript", b"0;")
241
+ if path == "/bridge.js":
242
+ js = _bridge_js().replace("__PORT__", str(self.port)).replace("__BUILD__", _bridge_build())
243
+ return await self._send_raw(writer, 200, "application/javascript", js.encode())
244
+ if path == "/log":
245
+ log.info("PT web view: %s", target)
246
+ return await self._send(writer, 200, {"ok": True})
247
+ if path in ("/", "/bridge"):
248
+ return await self._send_raw(writer, 200, "text/html; charset=utf-8",
249
+ _bridge_page(self.port).encode())
250
+ await self._send(writer, 404, {"error": "not found"})
251
+ except (asyncio.IncompleteReadError, ConnectionError):
252
+ pass
253
+ except Exception as e: # pragma: no cover - defensive
254
+ log.exception("http handler failed")
255
+ try:
256
+ await self._send(writer, 500, {"error": str(e)})
257
+ except Exception:
258
+ pass
259
+ finally:
260
+ try:
261
+ writer.close()
262
+ except Exception:
263
+ pass
264
+
265
+ async def _poll(self, writer: asyncio.StreamWriter, target: str) -> None:
266
+ self._last_seen = time.monotonic()
267
+ q: dict[str, str] = {}
268
+ if "?" in target:
269
+ from urllib.parse import parse_qs
270
+ q = {k: v[0] for k, v in parse_qs(target.split("?", 1)[1]).items()}
271
+ if q.get("pt"):
272
+ self._pt_info.update({"version": q.get("pt"), "session": q.get("s")})
273
+ try:
274
+ job = await asyncio.wait_for(self._queue.get(), POLL_HOLD_S)
275
+ except asyncio.TimeoutError:
276
+ self._last_seen = time.monotonic()
277
+ return await self._reply_poll(writer, q, {"id": 0})
278
+ if job.future.done(): # caller gave up while queued
279
+ return await self._reply_poll(writer, q, {"id": 0})
280
+ job.picked.set()
281
+ self._last_seen = time.monotonic()
282
+ try:
283
+ await self._reply_poll(writer, q, {"id": job.id, "code": job.code})
284
+ except ConnectionError:
285
+ await self._queue.put(job) # web view vanished mid-poll; retry on next poll
286
+
287
+ async def _reply_poll(self, writer, q: dict, obj: dict) -> None:
288
+ cb = q.get("cb", "")
289
+ if cb and cb.replace("_", "").isalnum(): # JSONP for the PT web view
290
+ body = f"{cb}({json.dumps({**obj, 'build': _bridge_build()})});".encode()
291
+ return await self._send_raw(writer, 200, "application/javascript", body)
292
+ await self._send(writer, 200, obj)
293
+
294
+ def _on_chunk(self, target: str) -> None:
295
+ from urllib.parse import parse_qs
296
+ q = {k: v[0] for k, v in parse_qs(target.split("?", 1)[1], keep_blank_values=True).items()}
297
+ jid, i, n = int(q["id"]), int(q["i"]), int(q["n"])
298
+ parts = self._chunks.setdefault(jid, {})
299
+ parts[i] = q.get("d", "")
300
+ if len(parts) == n:
301
+ del self._chunks[jid]
302
+ self._on_result(json.loads("".join(parts[k] for k in range(n))))
303
+
304
+ def _on_result(self, msg: dict) -> None:
305
+ job = self._inflight.get(int(msg.get("id", 0)))
306
+ if job and not job.future.done():
307
+ job.future.set_result(msg)
308
+
309
+ async def _exec_for_relay(self, writer, req: dict) -> None:
310
+ try:
311
+ value = await self._submit(req["code"], float(req.get("timeout", 60)))
312
+ await self._send(writer, 200, {"ok": True, "value": value})
313
+ except NotConnected as e:
314
+ await self._send(writer, 200, {"bridge_error": str(e), "not_connected": True})
315
+ except PTError as e:
316
+ await self._send(writer, 200, {"bridge_error": str(e)})
317
+
318
+ async def _send(self, writer, status: int, obj: Any) -> None:
319
+ body = b"" if obj is None else json.dumps(obj).encode()
320
+ await self._send_raw(writer, status, "application/json", body)
321
+
322
+ async def _send_raw(self, writer, status: int, ctype: str, body: bytes) -> None:
323
+ reason = {200: "OK", 204: "No Content", 404: "Not Found", 413: "Too Large",
324
+ 500: "Error"}.get(status, "OK")
325
+ hdr = (f"HTTP/1.1 {status} {reason}\r\nContent-Type: {ctype}\r\n"
326
+ f"Content-Length: {len(body)}\r\nAccess-Control-Allow-Origin: *\r\n"
327
+ "Access-Control-Allow-Headers: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
328
+ "Cache-Control: no-store\r\nConnection: close\r\n\r\n")
329
+ writer.write(hdr.encode() + body)
330
+ await writer.drain()
331
+
332
+
333
+ async def _read_chunked(reader: asyncio.StreamReader) -> bytes:
334
+ body = bytearray()
335
+ while True:
336
+ size = int((await reader.readline()).split(b";")[0].strip() or b"0", 16)
337
+ if size == 0:
338
+ await reader.readline()
339
+ return bytes(body)
340
+ if len(body) + size > MAX_BODY:
341
+ raise ConnectionError("body too large")
342
+ body += await reader.readexactly(size)
343
+ await reader.readline()
344
+
345
+
346
+ def _unwrap(env: dict) -> Any:
347
+ """PT side replies {ok, value} or {ok:false, error, line}."""
348
+ if isinstance(env, str):
349
+ env = json.loads(env)
350
+ if env.get("ok"):
351
+ return env.get("value")
352
+ msg = env.get("error") or "unknown Packet Tracer error"
353
+ if env.get("line"):
354
+ msg += f" (line {env['line']})"
355
+ raise PTError(msg)
356
+
357
+
358
+ def _bridge_page(port: int) -> str:
359
+ return resources.files("tracerlab").joinpath("js/bridge.html").read_text().replace("__PORT__", str(port))