sdev 0.7.2__tar.gz → 0.7.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.
- {sdev-0.7.2/sdev.egg-info → sdev-0.7.5}/PKG-INFO +45 -21
- {sdev-0.7.2 → sdev-0.7.5}/README.md +44 -20
- {sdev-0.7.2 → sdev-0.7.5}/pyproject.toml +1 -1
- {sdev-0.7.2 → sdev-0.7.5}/sdev/cli/__init__.py +130 -58
- {sdev-0.7.2 → sdev-0.7.5}/sdev/deprecated/demoboard.py +3 -5
- {sdev-0.7.2 → sdev-0.7.5}/sdev/modules/__init__.py +3 -4
- sdev-0.7.5/sdev/modules/mcp_server.py +124 -0
- {sdev-0.7.2 → sdev-0.7.5}/sdev/modules/serial_notebook.py +28 -9
- {sdev-0.7.2 → sdev-0.7.5}/sdev/modules/serial_rw.py +11 -3
- {sdev-0.7.2 → sdev-0.7.5}/sdev/modules/serial_rw_server.py +87 -9
- {sdev-0.7.2 → sdev-0.7.5/sdev.egg-info}/PKG-INFO +45 -21
- {sdev-0.7.2 → sdev-0.7.5}/sdev.egg-info/SOURCES.txt +1 -0
- {sdev-0.7.2 → sdev-0.7.5}/setup.py +2 -1
- {sdev-0.7.2 → sdev-0.7.5}/LICENSE +0 -0
- {sdev-0.7.2 → sdev-0.7.5}/MANIFEST.in +0 -0
- {sdev-0.7.2 → sdev-0.7.5}/sdev/__init__.py +0 -0
- {sdev-0.7.2 → sdev-0.7.5}/sdev/cli/__main__.py +0 -0
- {sdev-0.7.2 → sdev-0.7.5}/sdev.egg-info/dependency_links.txt +0 -0
- {sdev-0.7.2 → sdev-0.7.5}/sdev.egg-info/entry_points.txt +0 -0
- {sdev-0.7.2 → sdev-0.7.5}/sdev.egg-info/requires.txt +0 -0
- {sdev-0.7.2 → sdev-0.7.5}/sdev.egg-info/top_level.txt +0 -0
- {sdev-0.7.2 → sdev-0.7.5}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sdev
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.5
|
|
4
4
|
Summary: 串口控制器工具包
|
|
5
5
|
Home-page: https://github.com/klrc/sdev
|
|
6
6
|
Author: klrc
|
|
@@ -57,34 +57,41 @@ pip install sdev
|
|
|
57
57
|
|
|
58
58
|
## 命令行快速上手
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
-
|
|
63
|
-
- `
|
|
64
|
-
- `
|
|
60
|
+
安装后提供一个 `sdev` 命令,核心功能包括:
|
|
61
|
+
|
|
62
|
+
- **`sdev list [scope] [filters...]`**:扫描本地 / 远程可用串口。
|
|
63
|
+
- `scope`: `local`, `remote` 或不填。
|
|
64
|
+
- `filters`: 如 `type=XC01` 或 `host=192.168.1.101`。
|
|
65
|
+
- `-f, --fast`: 快速模式,找到第一个符合条件的设备后立即输出(适合脚本)。
|
|
66
|
+
- **`sdev run [cmd...]`**:执行命令。
|
|
67
|
+
- `-i, --interrupt-only`: 仅发送一次 Ctrl-C,随后被动收集输出(不发送新命令)。
|
|
68
|
+
- `-t, --timeout`: 整体超时限制。
|
|
69
|
+
- `-w, --wait-forever`: 永久等待输出,直至整体超时或手动中断。
|
|
70
|
+
- **`sdev server [action]`**:管理本机串口共享服务。
|
|
71
|
+
- `action`: `start`, `stop`, `restart`, `status`。
|
|
72
|
+
- **`sdev mcp [action]`**:AI 集成(Model Context Protocol)。
|
|
73
|
+
- `action`: `install` (一键配置到 IDE), `run` (由 IDE 调起的后台服务)。
|
|
65
74
|
- `sdev set` / `sdev show` / `sdev reset`:管理默认设备与缓存。
|
|
66
75
|
|
|
67
76
|
典型流程:
|
|
68
77
|
|
|
69
78
|
```bash
|
|
70
|
-
# 1.
|
|
79
|
+
# 1. 在“串口所在机器”上启动 server
|
|
71
80
|
sdev server start
|
|
72
81
|
|
|
73
|
-
# 2.
|
|
74
|
-
sdev list
|
|
82
|
+
# 2. 扫描并筛选可用设备
|
|
83
|
+
sdev list remote # 仅扫描远程
|
|
84
|
+
sdev list type=XC01 # 仅列出特定型号
|
|
85
|
+
sdev list -f type=XC01 # 快速模式:找到第一个 XC01 立即返回(适合脚本)
|
|
75
86
|
|
|
76
|
-
# 3.
|
|
87
|
+
# 3. 选中一块板,配置为默认设备
|
|
77
88
|
sdev set default <DEVICE_ID>
|
|
78
89
|
|
|
79
|
-
# 4.
|
|
80
|
-
sdev run 'cat /proc/meminfo'
|
|
81
|
-
sdev run -
|
|
90
|
+
# 4. 执行命令
|
|
91
|
+
sdev run 'cat /proc/meminfo' # 执行完整交互
|
|
92
|
+
sdev run -i # 仅发送 Ctrl-C (用于中断正在跑的任务并查看输出)
|
|
82
93
|
```
|
|
83
94
|
|
|
84
|
-
更详细的 CLI 用法与行为说明,参考项目内的 skill 文档:
|
|
85
|
-
|
|
86
|
-
- `docs/skills/serial-sdev-cli/SKILL.md`
|
|
87
|
-
|
|
88
95
|
---
|
|
89
96
|
|
|
90
97
|
## Python API 快速上手(SerialNotebook)
|
|
@@ -125,6 +132,25 @@ with SerialNotebook(device=SERIAL_PORT, baudrate=115200, host=HOST, port=7000) a
|
|
|
125
132
|
|
|
126
133
|
---
|
|
127
134
|
|
|
135
|
+
## AI 集成 (MCP)
|
|
136
|
+
|
|
137
|
+
`sdev` 支持 **Model Context Protocol (MCP)**,允许 AI 助手(如 Cursor、Claude Desktop)直接操作串口,执行命令并感知设备状态。
|
|
138
|
+
|
|
139
|
+
### 1. 获取配置信息
|
|
140
|
+
在本地环境执行:
|
|
141
|
+
```bash
|
|
142
|
+
sdev mcp install
|
|
143
|
+
```
|
|
144
|
+
该命令会自动生成可用于粘贴的 JSON 配置片段,并提示 Cursor 或 Claude Desktop 的配置路径。
|
|
145
|
+
|
|
146
|
+
### 2. 提供的能力 (Tools)
|
|
147
|
+
安装后,AI 助手将获得以下工具:
|
|
148
|
+
- `sdev_list`: 获取本地及远程可用设备列表。
|
|
149
|
+
- `sdev_run`: 在指定设备上执行命令并获取输出。
|
|
150
|
+
- `sdev_set_default`: 设置当前会话的默认设备。
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
128
154
|
## 适用 / 不适用的场景
|
|
129
155
|
|
|
130
156
|
**适用:**
|
|
@@ -143,9 +169,6 @@ with SerialNotebook(device=SERIAL_PORT, baudrate=115200, host=HOST, port=7000) a
|
|
|
143
169
|
## 更多文档
|
|
144
170
|
|
|
145
171
|
- **内部架构与设计说明**:`docs/arch_design.md`
|
|
146
|
-
- **技能(在 Cursor 里集成使用方式)**:
|
|
147
|
-
- `docs/skills/serial-sdev-cli/SKILL.md`
|
|
148
|
-
- `docs/skills/serial-sdev-api/SKILL.md`
|
|
149
172
|
|
|
150
173
|
源码结构概览(仅列出现行实现):
|
|
151
174
|
|
|
@@ -153,5 +176,6 @@ with SerialNotebook(device=SERIAL_PORT, baudrate=115200, host=HOST, port=7000) a
|
|
|
153
176
|
- `serial_rw.py`:本地串口读写与锁管理(`SerialRW`)、运行控制器(`SerialRunner`)、端口扫描与快速探测;
|
|
154
177
|
- `serial_rw_server.py`:远程串口共享服务与客户端(`SerialRWServer` / `SerialRWClient`),以及主机发现逻辑;
|
|
155
178
|
- `serial_notebook.py`:Notebook / 脚本场景下的最高层 Python 入口(`SerialNotebook`),基于 `SerialRW` / `SerialRWClient` 实现。
|
|
156
|
-
- `sdev/cli/`:命令行入口 `sdev`(`__init__.py` 中的 `main()
|
|
179
|
+
- `sdev/cli/`:命令行入口 `sdev`(`__init__.py` 中的 `main()`),以 `SerialNotebook` 作为上层统一入口,并通过 `SerialRWServer`/`SerialRWClient` 实现远程 `run / list / server` 等子命令。
|
|
180
|
+
|
|
157
181
|
|
|
@@ -22,34 +22,41 @@ pip install sdev
|
|
|
22
22
|
|
|
23
23
|
## 命令行快速上手
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
-
|
|
28
|
-
- `
|
|
29
|
-
- `
|
|
25
|
+
安装后提供一个 `sdev` 命令,核心功能包括:
|
|
26
|
+
|
|
27
|
+
- **`sdev list [scope] [filters...]`**:扫描本地 / 远程可用串口。
|
|
28
|
+
- `scope`: `local`, `remote` 或不填。
|
|
29
|
+
- `filters`: 如 `type=XC01` 或 `host=192.168.1.101`。
|
|
30
|
+
- `-f, --fast`: 快速模式,找到第一个符合条件的设备后立即输出(适合脚本)。
|
|
31
|
+
- **`sdev run [cmd...]`**:执行命令。
|
|
32
|
+
- `-i, --interrupt-only`: 仅发送一次 Ctrl-C,随后被动收集输出(不发送新命令)。
|
|
33
|
+
- `-t, --timeout`: 整体超时限制。
|
|
34
|
+
- `-w, --wait-forever`: 永久等待输出,直至整体超时或手动中断。
|
|
35
|
+
- **`sdev server [action]`**:管理本机串口共享服务。
|
|
36
|
+
- `action`: `start`, `stop`, `restart`, `status`。
|
|
37
|
+
- **`sdev mcp [action]`**:AI 集成(Model Context Protocol)。
|
|
38
|
+
- `action`: `install` (一键配置到 IDE), `run` (由 IDE 调起的后台服务)。
|
|
30
39
|
- `sdev set` / `sdev show` / `sdev reset`:管理默认设备与缓存。
|
|
31
40
|
|
|
32
41
|
典型流程:
|
|
33
42
|
|
|
34
43
|
```bash
|
|
35
|
-
# 1.
|
|
44
|
+
# 1. 在“串口所在机器”上启动 server
|
|
36
45
|
sdev server start
|
|
37
46
|
|
|
38
|
-
# 2.
|
|
39
|
-
sdev list
|
|
47
|
+
# 2. 扫描并筛选可用设备
|
|
48
|
+
sdev list remote # 仅扫描远程
|
|
49
|
+
sdev list type=XC01 # 仅列出特定型号
|
|
50
|
+
sdev list -f type=XC01 # 快速模式:找到第一个 XC01 立即返回(适合脚本)
|
|
40
51
|
|
|
41
|
-
# 3.
|
|
52
|
+
# 3. 选中一块板,配置为默认设备
|
|
42
53
|
sdev set default <DEVICE_ID>
|
|
43
54
|
|
|
44
|
-
# 4.
|
|
45
|
-
sdev run 'cat /proc/meminfo'
|
|
46
|
-
sdev run -
|
|
55
|
+
# 4. 执行命令
|
|
56
|
+
sdev run 'cat /proc/meminfo' # 执行完整交互
|
|
57
|
+
sdev run -i # 仅发送 Ctrl-C (用于中断正在跑的任务并查看输出)
|
|
47
58
|
```
|
|
48
59
|
|
|
49
|
-
更详细的 CLI 用法与行为说明,参考项目内的 skill 文档:
|
|
50
|
-
|
|
51
|
-
- `docs/skills/serial-sdev-cli/SKILL.md`
|
|
52
|
-
|
|
53
60
|
---
|
|
54
61
|
|
|
55
62
|
## Python API 快速上手(SerialNotebook)
|
|
@@ -90,6 +97,25 @@ with SerialNotebook(device=SERIAL_PORT, baudrate=115200, host=HOST, port=7000) a
|
|
|
90
97
|
|
|
91
98
|
---
|
|
92
99
|
|
|
100
|
+
## AI 集成 (MCP)
|
|
101
|
+
|
|
102
|
+
`sdev` 支持 **Model Context Protocol (MCP)**,允许 AI 助手(如 Cursor、Claude Desktop)直接操作串口,执行命令并感知设备状态。
|
|
103
|
+
|
|
104
|
+
### 1. 获取配置信息
|
|
105
|
+
在本地环境执行:
|
|
106
|
+
```bash
|
|
107
|
+
sdev mcp install
|
|
108
|
+
```
|
|
109
|
+
该命令会自动生成可用于粘贴的 JSON 配置片段,并提示 Cursor 或 Claude Desktop 的配置路径。
|
|
110
|
+
|
|
111
|
+
### 2. 提供的能力 (Tools)
|
|
112
|
+
安装后,AI 助手将获得以下工具:
|
|
113
|
+
- `sdev_list`: 获取本地及远程可用设备列表。
|
|
114
|
+
- `sdev_run`: 在指定设备上执行命令并获取输出。
|
|
115
|
+
- `sdev_set_default`: 设置当前会话的默认设备。
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
93
119
|
## 适用 / 不适用的场景
|
|
94
120
|
|
|
95
121
|
**适用:**
|
|
@@ -108,9 +134,6 @@ with SerialNotebook(device=SERIAL_PORT, baudrate=115200, host=HOST, port=7000) a
|
|
|
108
134
|
## 更多文档
|
|
109
135
|
|
|
110
136
|
- **内部架构与设计说明**:`docs/arch_design.md`
|
|
111
|
-
- **技能(在 Cursor 里集成使用方式)**:
|
|
112
|
-
- `docs/skills/serial-sdev-cli/SKILL.md`
|
|
113
|
-
- `docs/skills/serial-sdev-api/SKILL.md`
|
|
114
137
|
|
|
115
138
|
源码结构概览(仅列出现行实现):
|
|
116
139
|
|
|
@@ -118,5 +141,6 @@ with SerialNotebook(device=SERIAL_PORT, baudrate=115200, host=HOST, port=7000) a
|
|
|
118
141
|
- `serial_rw.py`:本地串口读写与锁管理(`SerialRW`)、运行控制器(`SerialRunner`)、端口扫描与快速探测;
|
|
119
142
|
- `serial_rw_server.py`:远程串口共享服务与客户端(`SerialRWServer` / `SerialRWClient`),以及主机发现逻辑;
|
|
120
143
|
- `serial_notebook.py`:Notebook / 脚本场景下的最高层 Python 入口(`SerialNotebook`),基于 `SerialRW` / `SerialRWClient` 实现。
|
|
121
|
-
- `sdev/cli/`:命令行入口 `sdev`(`__init__.py` 中的 `main()
|
|
144
|
+
- `sdev/cli/`:命令行入口 `sdev`(`__init__.py` 中的 `main()`),以 `SerialNotebook` 作为上层统一入口,并通过 `SerialRWServer`/`SerialRWClient` 实现远程 `run / list / server` 等子命令。
|
|
145
|
+
|
|
122
146
|
|
|
@@ -23,12 +23,11 @@ from datetime import datetime
|
|
|
23
23
|
from typing import Any, Dict, List, Optional, Tuple
|
|
24
24
|
|
|
25
25
|
from ..modules import (
|
|
26
|
-
SerialRW,
|
|
27
|
-
SerialRunner,
|
|
28
26
|
check_port_alive_nonblock,
|
|
29
27
|
scan_serial_ports,
|
|
30
28
|
DEFAULT_SERVICE_PORT,
|
|
31
29
|
)
|
|
30
|
+
from ..modules.serial_notebook import SerialNotebook
|
|
32
31
|
from ..modules.serial_rw_server import (
|
|
33
32
|
SerialRWClient,
|
|
34
33
|
fetch_boards_from_host,
|
|
@@ -141,9 +140,9 @@ def _read_default() -> Optional[Dict[str, Any]]:
|
|
|
141
140
|
return None
|
|
142
141
|
|
|
143
142
|
|
|
144
|
-
def _write_default(host: str,
|
|
143
|
+
def _write_default(host: str, device: str, device_id: str) -> None:
|
|
145
144
|
with open(_default_config_path(), "w", encoding="utf-8") as f:
|
|
146
|
-
json.dump({"host": host, "
|
|
145
|
+
json.dump({"host": host, "device": device, "device_id": device_id}, f, ensure_ascii=False)
|
|
147
146
|
|
|
148
147
|
|
|
149
148
|
def _clear_default() -> None:
|
|
@@ -153,10 +152,10 @@ def _clear_default() -> None:
|
|
|
153
152
|
|
|
154
153
|
|
|
155
154
|
def _resolve_device_to_host_port(device_id: str) -> Optional[Tuple[str, str]]:
|
|
156
|
-
"""从 list 缓存中按 device_id 解析出 (host,
|
|
155
|
+
"""从 list 缓存中按 device_id 解析出 (host, device);未找到返回 None。"""
|
|
157
156
|
for e in _read_list_cache():
|
|
158
157
|
if (e.get("device_id") or "").lower() == device_id.lower():
|
|
159
|
-
return (e.get("host", "localhost"), e.get("
|
|
158
|
+
return (e.get("host", "localhost"), e.get("device", ""))
|
|
160
159
|
return None
|
|
161
160
|
|
|
162
161
|
|
|
@@ -279,6 +278,14 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
279
278
|
help="status 时显示的最近日志行数,默认 10",
|
|
280
279
|
)
|
|
281
280
|
|
|
281
|
+
# sdev mcp run | install
|
|
282
|
+
mcp_p = subparsers.add_parser("mcp", help="MCP (Model Context Protocol) 相关工具")
|
|
283
|
+
mcp_p.add_argument(
|
|
284
|
+
"action",
|
|
285
|
+
choices=["run", "install"],
|
|
286
|
+
help="run=启动 MCP 服务(stdio传输);install=获取可粘贴的配置信息",
|
|
287
|
+
)
|
|
288
|
+
|
|
282
289
|
return parser
|
|
283
290
|
|
|
284
291
|
|
|
@@ -299,52 +306,60 @@ def _handle_run(args: argparse.Namespace) -> int:
|
|
|
299
306
|
if not default_cfg:
|
|
300
307
|
raise SystemExit("sdev run: 未指定 --port 且未配置默认设备,请先执行 sdev set default <device_id> 或 sdev set <host> <serial_device>")
|
|
301
308
|
host = (default_cfg.get("host") or "").strip() or "localhost"
|
|
302
|
-
|
|
303
|
-
if not
|
|
304
|
-
raise SystemExit("sdev run: 默认配置缺少
|
|
309
|
+
device = (default_cfg.get("device") or default_cfg.get("port") or "").strip()
|
|
310
|
+
if not device:
|
|
311
|
+
raise SystemExit("sdev run: 默认配置缺少 device")
|
|
305
312
|
else:
|
|
306
313
|
host = "localhost"
|
|
314
|
+
device = port
|
|
307
315
|
|
|
308
|
-
|
|
309
|
-
|
|
316
|
+
overall_timeout: Optional[float] = args.timeout
|
|
317
|
+
# 若未显式指定整体超时,CLI 默认给一个有限的响应超时,避免在无输出设备上永久挂起。
|
|
318
|
+
if args.wait_forever:
|
|
319
|
+
response_timeout: Optional[float] = None
|
|
310
320
|
else:
|
|
311
|
-
|
|
312
|
-
core = SerialRWClient(host, DEFAULT_SERVICE_PORT, port, args.baud)
|
|
313
|
-
runner = SerialRunner(core)
|
|
321
|
+
response_timeout = overall_timeout if overall_timeout is not None else 2.0
|
|
314
322
|
|
|
315
|
-
|
|
316
|
-
|
|
323
|
+
# CLI 统一通过 SerialNotebook 作为上层入口:
|
|
324
|
+
# - 本地:直接使用本机串口;
|
|
325
|
+
# - 远程:通过 SerialRWClient 由 SerialNotebook 内部接管。
|
|
326
|
+
if (host or "").lower() == "localhost":
|
|
327
|
+
nb = SerialNotebook(device=device, baudrate=args.baud)
|
|
328
|
+
else:
|
|
329
|
+
nb = SerialNotebook(device=device, baudrate=args.baud, host=host, port=DEFAULT_SERVICE_PORT)
|
|
317
330
|
|
|
318
|
-
with
|
|
319
|
-
# CLI 下每条命令(或一次 interrupt-only)前空一行,便于与上一次输出隔离;脚本/API 不经过此处,无多余空行。
|
|
320
|
-
print()
|
|
331
|
+
with nb:
|
|
321
332
|
try:
|
|
322
333
|
if interrupt_only:
|
|
323
|
-
#
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
)
|
|
334
|
+
# 仅发送一次中断信号,然后在较短窗口内被动收集输出。
|
|
335
|
+
nb.send_interrupt()
|
|
336
|
+
lines = nb.wait_for_silence(timeout=response_timeout or 1.0)
|
|
337
|
+
# 为保持行为简单,这里仅以灰色输出剩余行,不再额外做提示符高亮。
|
|
338
|
+
GREY = "\033[90m"
|
|
339
|
+
RESET = "\033[0m"
|
|
340
|
+
for line in lines:
|
|
341
|
+
text = getattr(line, "text", str(line))
|
|
342
|
+
sys.stdout.write(f"{GREY}{text}{RESET}")
|
|
343
|
+
sys.stdout.flush()
|
|
334
344
|
else:
|
|
335
345
|
# 正常执行命令。
|
|
336
|
-
|
|
337
|
-
cmd_str,
|
|
338
|
-
|
|
346
|
+
ret = nb.run(
|
|
347
|
+
cmd=cmd_str,
|
|
348
|
+
end_flag=args.prompt_flag,
|
|
339
349
|
overall_timeout=overall_timeout,
|
|
340
350
|
response_timeout=response_timeout,
|
|
341
|
-
wait_forever=args.wait_forever,
|
|
342
351
|
stream=False,
|
|
343
|
-
|
|
352
|
+
verbose=False,
|
|
353
|
+
keep_type=True,
|
|
344
354
|
)
|
|
355
|
+
for line in ret:
|
|
356
|
+
if line.kind == 'body' or line.kind == 'error':
|
|
357
|
+
sys.stdout.write(line.text)
|
|
358
|
+
sys.stdout.flush()
|
|
359
|
+
print()
|
|
345
360
|
except KeyboardInterrupt:
|
|
346
361
|
# 捕获本地 Ctrl-C:向开发板发送一次中断信号(Ctrl-C),尽量中断远端前台命令。
|
|
347
|
-
|
|
362
|
+
nb.send_interrupt()
|
|
348
363
|
# 提示用户本次为主动中断(黄色)。
|
|
349
364
|
YELLOW = "\033[33m"
|
|
350
365
|
RESET = "\033[0m"
|
|
@@ -425,13 +440,8 @@ def _handle_list(args: argparse.Namespace) -> int:
|
|
|
425
440
|
|
|
426
441
|
def _model_one(port: str) -> Tuple[str, str]:
|
|
427
442
|
try:
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
core.connect()
|
|
431
|
-
try:
|
|
432
|
-
return (port, runner.check_model_type(timeout=2.0) or "unknown")
|
|
433
|
-
finally:
|
|
434
|
-
core.disconnect()
|
|
443
|
+
with SerialNotebook(device=port, baudrate=115200) as nb:
|
|
444
|
+
return (port, nb.get_model_type(timeout=2.0, verbose=False) or "unknown")
|
|
435
445
|
except Exception as e:
|
|
436
446
|
return (port, f"error: {e}")
|
|
437
447
|
|
|
@@ -445,7 +455,7 @@ def _handle_list(args: argparse.Namespace) -> int:
|
|
|
445
455
|
for port, device_type in model_results:
|
|
446
456
|
entries.append({
|
|
447
457
|
"host": "localhost",
|
|
448
|
-
"
|
|
458
|
+
"device": port,
|
|
449
459
|
"device_id": _device_id("localhost", port),
|
|
450
460
|
"device_type": device_type if not device_type.startswith("error:") else "unknown",
|
|
451
461
|
"last_update": now,
|
|
@@ -455,32 +465,32 @@ def _handle_list(args: argparse.Namespace) -> int:
|
|
|
455
465
|
def _scan_remote_task() -> List[Dict[str, Any]]:
|
|
456
466
|
entries: List[Dict[str, Any]] = []
|
|
457
467
|
hosts = get_remote_hosts()
|
|
468
|
+
# print(f"[debug] remote hosts found by discovery: {hosts}")
|
|
458
469
|
if not hosts:
|
|
459
470
|
return entries
|
|
460
471
|
for host, service_port in hosts:
|
|
472
|
+
# print(f"[debug] fetching boards from {host}:{service_port}...")
|
|
461
473
|
boards = fetch_boards_from_host(host, service_port, timeout=10.0)
|
|
474
|
+
# print(f"[debug] host {host} returned {len(boards)} boards")
|
|
462
475
|
for b in boards:
|
|
463
476
|
if not b.get("available"):
|
|
464
477
|
continue
|
|
465
|
-
|
|
466
|
-
if not
|
|
478
|
+
serial_dev = (b.get("device") or b.get("port") or "").strip()
|
|
479
|
+
if not serial_dev:
|
|
467
480
|
continue
|
|
468
481
|
baud = int(b.get("baudrate") or 115200)
|
|
469
482
|
device_type = "unknown"
|
|
470
483
|
try:
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
finally:
|
|
477
|
-
core.disconnect()
|
|
478
|
-
except Exception:
|
|
484
|
+
with SerialNotebook(device=serial_dev, baudrate=baud, host=host, port=service_port) as nb:
|
|
485
|
+
device_type = nb.get_model_type(timeout=3.0, verbose=False) or "unknown"
|
|
486
|
+
except Exception as e:
|
|
487
|
+
# 如果报错,说明连接或者握手阶段就出问题了
|
|
488
|
+
# print(f"[debug] failed to get model type from {host}:{serial_dev}: {e}")
|
|
479
489
|
device_type = "unknown"
|
|
480
490
|
entries.append({
|
|
481
491
|
"host": host,
|
|
482
|
-
"
|
|
483
|
-
"device_id": _device_id(host,
|
|
492
|
+
"device": serial_dev,
|
|
493
|
+
"device_id": _device_id(host, serial_dev),
|
|
484
494
|
"device_type": device_type if not device_type.startswith("error:") else "unknown",
|
|
485
495
|
"last_update": now,
|
|
486
496
|
})
|
|
@@ -532,6 +542,9 @@ def _handle_list(args: argparse.Namespace) -> int:
|
|
|
532
542
|
entries = [e for e in entries if (e.get("device_type") or "").lower() == filters["type"].lower()]
|
|
533
543
|
if "host" in filters:
|
|
534
544
|
entries = [e for e in entries if (e.get("host") or "").lower() == filters["host"].lower()]
|
|
545
|
+
|
|
546
|
+
# 默认展示所有探测到的设备,即便型号识别为 unknown。
|
|
547
|
+
# entries = [e for e in entries if (e.get("device_type") or "").lower() != "unknown"]
|
|
535
548
|
if fast and len(entries) > 1:
|
|
536
549
|
entries = entries[:1]
|
|
537
550
|
|
|
@@ -548,7 +561,7 @@ def _handle_list(args: argparse.Namespace) -> int:
|
|
|
548
561
|
for e in entries:
|
|
549
562
|
print(
|
|
550
563
|
f"{e.get('host', ''):<{col_host}} "
|
|
551
|
-
f"{e.get('
|
|
564
|
+
f"{e.get('device', ''):<{col_port}} "
|
|
552
565
|
f"{e.get('device_id', ''):<{col_id}} "
|
|
553
566
|
f"{e.get('device_type', 'unknown'):<{col_type}} "
|
|
554
567
|
f"{e.get('last_update', ''):<{col_time}}"
|
|
@@ -726,7 +739,7 @@ def _handle_reset(args: argparse.Namespace) -> int:
|
|
|
726
739
|
if _server_is_running() is not None:
|
|
727
740
|
_handle_server_stop()
|
|
728
741
|
_clear_default()
|
|
729
|
-
list_path =
|
|
742
|
+
list_path = _list_cache_path()
|
|
730
743
|
if os.path.isfile(list_path):
|
|
731
744
|
os.remove(list_path)
|
|
732
745
|
print(f"[{_ts()}] list cache cleared")
|
|
@@ -734,6 +747,63 @@ def _handle_reset(args: argparse.Namespace) -> int:
|
|
|
734
747
|
return 0
|
|
735
748
|
|
|
736
749
|
|
|
750
|
+
def _handle_mcp(args: argparse.Namespace) -> int:
|
|
751
|
+
action = getattr(args, "action", None)
|
|
752
|
+
if action == "run":
|
|
753
|
+
# 启动 MCP 服务
|
|
754
|
+
from ..modules.mcp_server import mcp
|
|
755
|
+
mcp.run()
|
|
756
|
+
return 0
|
|
757
|
+
if action == "install":
|
|
758
|
+
return _mcp_show_config()
|
|
759
|
+
return 1
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _mcp_show_config() -> int:
|
|
763
|
+
import platform
|
|
764
|
+
import json
|
|
765
|
+
|
|
766
|
+
# 确定 Python 路径
|
|
767
|
+
py_path = sys.executable
|
|
768
|
+
# 确定项目根目录(用于 PYTHONPATH)
|
|
769
|
+
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
770
|
+
|
|
771
|
+
# 封装配置对象
|
|
772
|
+
sdev_config = {
|
|
773
|
+
"command": py_path,
|
|
774
|
+
"args": ["-m", "sdev.modules.mcp_server"],
|
|
775
|
+
"env": {
|
|
776
|
+
"PYTHONPATH": project_root
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
system = platform.system()
|
|
781
|
+
home = os.path.expanduser("~")
|
|
782
|
+
|
|
783
|
+
print(f"\n[{_ts()}] === SDEV MCP 配置指南 ===")
|
|
784
|
+
|
|
785
|
+
# 1. 针对 Cursor
|
|
786
|
+
print(f"\n[Cursor 推荐路径]:")
|
|
787
|
+
if system == "Linux":
|
|
788
|
+
print(f" {home}/.cursor/mcp.json")
|
|
789
|
+
elif system == "Darwin":
|
|
790
|
+
print(f" ~/Library/Application Support/Cursor/User/globalStorage/rosebud.cursor-ide-browser/mcp.json")
|
|
791
|
+
|
|
792
|
+
print(f"\n[Cursor/Claude 粘贴内容 (sdev 字段)]: ")
|
|
793
|
+
print(json.dumps(sdev_config, indent=2, ensure_ascii=False))
|
|
794
|
+
|
|
795
|
+
print(f"\n[完整示例 (mcp.json)]: ")
|
|
796
|
+
full_example = {
|
|
797
|
+
"mcpServers": {
|
|
798
|
+
"sdev": sdev_config
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
print(json.dumps(full_example, indent=2, ensure_ascii=False))
|
|
802
|
+
|
|
803
|
+
print(f"\n[{_ts()}] 请将上述内容添加到你的 MCP 配置文件中,并重启 AI 客户端。")
|
|
804
|
+
return 0
|
|
805
|
+
|
|
806
|
+
|
|
737
807
|
def main(argv: Optional[list[str]] = None) -> int:
|
|
738
808
|
parser = _build_parser()
|
|
739
809
|
args = parser.parse_args(argv)
|
|
@@ -750,6 +820,8 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|
|
750
820
|
return _handle_reset(args)
|
|
751
821
|
if args.command == "server":
|
|
752
822
|
return _handle_server(args)
|
|
823
|
+
if args.command == "mcp":
|
|
824
|
+
return _handle_mcp(args)
|
|
753
825
|
|
|
754
826
|
parser.error(f"未知子命令: {args.command}")
|
|
755
827
|
return 1
|
|
@@ -105,17 +105,15 @@ class Demoboard:
|
|
|
105
105
|
response_timeout = None if wait_forever else timeout
|
|
106
106
|
|
|
107
107
|
def _iter_cmd() -> Generator[str, None, None]:
|
|
108
|
-
|
|
108
|
+
return self._nb.run(
|
|
109
109
|
cmd=cmd,
|
|
110
110
|
end_flag=flag,
|
|
111
111
|
overall_timeout=timeout,
|
|
112
112
|
response_timeout=response_timeout,
|
|
113
113
|
stream=True,
|
|
114
114
|
verbose=True,
|
|
115
|
-
)
|
|
116
|
-
|
|
117
|
-
yield text
|
|
118
|
-
|
|
115
|
+
)
|
|
116
|
+
|
|
119
117
|
if stream:
|
|
120
118
|
return _iter_cmd()
|
|
121
119
|
return list(_iter_cmd())
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from .serial_rw import
|
|
4
|
-
from .serial_rw_server import
|
|
3
|
+
from .serial_rw import scan_serial_ports, check_port_alive_nonblock
|
|
4
|
+
from .serial_rw_server import DEFAULT_SERVICE_PORT, SerialRWServer
|
|
5
5
|
from .serial_notebook import SerialNotebook
|
|
6
6
|
|
|
7
7
|
__all__ = [
|
|
8
|
-
"
|
|
9
|
-
"SerialRWClient",
|
|
8
|
+
"SerialNotebook",
|
|
10
9
|
"SerialRWServer",
|
|
11
10
|
"scan_serial_ports",
|
|
12
11
|
"check_port_alive_nonblock",
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from mcp.server.fastmcp import FastMCP
|
|
2
|
+
from .serial_notebook import SerialNotebook
|
|
3
|
+
from .serial_rw_server import get_remote_hosts, fetch_boards_from_host, DEFAULT_SERVICE_PORT
|
|
4
|
+
from .serial_rw import scan_serial_ports, check_port_alive_nonblock
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
# 初始化 FastMCP
|
|
10
|
+
mcp = FastMCP("sdev")
|
|
11
|
+
|
|
12
|
+
@mcp.tool()
|
|
13
|
+
def sdev_list(scope: str = "all") -> str:
|
|
14
|
+
"""
|
|
15
|
+
获取可用串口设备列表。
|
|
16
|
+
scope: "all" (默认), "local" (仅本地), "remote" (仅远程)。
|
|
17
|
+
返回格式化的设备列表字符串。
|
|
18
|
+
"""
|
|
19
|
+
from ..cli import _device_id
|
|
20
|
+
|
|
21
|
+
local_ports = []
|
|
22
|
+
if scope in ("all", "local"):
|
|
23
|
+
local_ports = scan_serial_ports()
|
|
24
|
+
|
|
25
|
+
remote_hosts = []
|
|
26
|
+
if scope in ("all", "remote"):
|
|
27
|
+
try:
|
|
28
|
+
remote_hosts = get_remote_hosts()
|
|
29
|
+
except:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
results = []
|
|
33
|
+
|
|
34
|
+
# 本地扫描
|
|
35
|
+
for p in local_ports:
|
|
36
|
+
r = check_port_alive_nonblock(p, timeout=1.0)
|
|
37
|
+
if r.get("available"):
|
|
38
|
+
# 尝试读型号
|
|
39
|
+
try:
|
|
40
|
+
with SerialNotebook(device=p) as nb:
|
|
41
|
+
model = nb.get_model_type(timeout=2.0) or "unknown"
|
|
42
|
+
except:
|
|
43
|
+
model = "unknown"
|
|
44
|
+
results.append(f"HOST: localhost, DEVICE: {p}, ID: {_device_id('localhost', p)}, TYPE: {model}")
|
|
45
|
+
|
|
46
|
+
# 远程扫描
|
|
47
|
+
for host, port in remote_hosts:
|
|
48
|
+
try:
|
|
49
|
+
boards = fetch_boards_from_host(host, port)
|
|
50
|
+
for b in boards:
|
|
51
|
+
if b.get("available"):
|
|
52
|
+
dev = b.get("device") or b.get("port")
|
|
53
|
+
try:
|
|
54
|
+
with SerialNotebook(device=dev, host=host, port=port) as nb:
|
|
55
|
+
model = nb.get_model_type(timeout=2.0) or "unknown"
|
|
56
|
+
except:
|
|
57
|
+
model = "unknown"
|
|
58
|
+
results.append(f"HOST: {host}, DEVICE: {dev}, ID: {_device_id(host, dev)}, TYPE: {model}")
|
|
59
|
+
except:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
if not results:
|
|
63
|
+
return "未发现可用设备。"
|
|
64
|
+
return "\n".join(results)
|
|
65
|
+
|
|
66
|
+
@mcp.tool()
|
|
67
|
+
def sdev_run(cmd: str, device_id: str = None, timeout: float = 10.0) -> str:
|
|
68
|
+
"""
|
|
69
|
+
在指定设备上执行命令。
|
|
70
|
+
cmd: 要执行的 shell 命令。
|
|
71
|
+
device_id: 设备 ID(从 sdev_list 获取)。如果不指定,则尝试使用默认设备。
|
|
72
|
+
timeout: 整体超时时间(秒)。
|
|
73
|
+
"""
|
|
74
|
+
from ..cli import _read_default, _resolve_device_to_host_port
|
|
75
|
+
|
|
76
|
+
host = "localhost"
|
|
77
|
+
device = None
|
|
78
|
+
|
|
79
|
+
if device_id:
|
|
80
|
+
res = _resolve_device_to_host_port(device_id)
|
|
81
|
+
if res:
|
|
82
|
+
host, device = res
|
|
83
|
+
else:
|
|
84
|
+
return f"错误:未找到 ID 为 {device_id} 的设备。"
|
|
85
|
+
else:
|
|
86
|
+
cfg = _read_default()
|
|
87
|
+
if cfg:
|
|
88
|
+
host = cfg.get("host", "localhost")
|
|
89
|
+
device = cfg.get("device") or cfg.get("port")
|
|
90
|
+
else:
|
|
91
|
+
return "错误:未指定 device_id 且没有配置默认设备。"
|
|
92
|
+
|
|
93
|
+
if not device:
|
|
94
|
+
return "错误:无法确定串口路径。"
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
# 使用 SerialNotebook 执行
|
|
98
|
+
if host == "localhost":
|
|
99
|
+
nb = SerialNotebook(device=device)
|
|
100
|
+
else:
|
|
101
|
+
nb = SerialNotebook(device=device, host=host, port=DEFAULT_SERVICE_PORT)
|
|
102
|
+
|
|
103
|
+
with nb:
|
|
104
|
+
lines = nb.run(cmd, overall_timeout=timeout, stream=False, verbose=False)
|
|
105
|
+
return "".join(lines).strip()
|
|
106
|
+
except Exception as e:
|
|
107
|
+
return f"执行失败:{e}"
|
|
108
|
+
|
|
109
|
+
@mcp.tool()
|
|
110
|
+
def sdev_set_default(device_id: str) -> str:
|
|
111
|
+
"""
|
|
112
|
+
设置默认操作的设备。
|
|
113
|
+
device_id: 设备 ID。
|
|
114
|
+
"""
|
|
115
|
+
from ..cli import _resolve_device_to_host_port, _write_default
|
|
116
|
+
res = _resolve_device_to_host_port(device_id)
|
|
117
|
+
if not res:
|
|
118
|
+
return f"错误:未找到 ID 为 {device_id} 的设备。"
|
|
119
|
+
host, device = res
|
|
120
|
+
_write_default(host, device, device_id)
|
|
121
|
+
return f"成功:已将默认设备设置为 {host}:{device} (ID: {device_id})"
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
mcp.run()
|
|
@@ -3,7 +3,7 @@ import sys
|
|
|
3
3
|
import time
|
|
4
4
|
from loguru import logger
|
|
5
5
|
|
|
6
|
-
from .serial_rw import
|
|
6
|
+
from .serial_rw import SerialLine, CTRL_C
|
|
7
7
|
|
|
8
8
|
GREY = "\033[90m"
|
|
9
9
|
YELLOW = "\033[33m"
|
|
@@ -13,9 +13,10 @@ RESET = "\033[0m"
|
|
|
13
13
|
class SerialNotebook():
|
|
14
14
|
def __init__(self, device: str, baudrate: int = 115200, host=None, port=None, default_flag=" #"):
|
|
15
15
|
if host is not None and port is not None:
|
|
16
|
-
from serial_rw_server import SerialRWClient
|
|
16
|
+
from .serial_rw_server import SerialRWClient
|
|
17
17
|
self._core = SerialRWClient(host, port, device, baudrate)
|
|
18
18
|
else:
|
|
19
|
+
from .serial_rw import SerialRW
|
|
19
20
|
self._core = SerialRW(device, baudrate)
|
|
20
21
|
self.default_flag = default_flag
|
|
21
22
|
self._muted = False
|
|
@@ -40,7 +41,7 @@ class SerialNotebook():
|
|
|
40
41
|
def __exit__(self, exc_type, exc_value, traceback):
|
|
41
42
|
self._core.disconnect()
|
|
42
43
|
|
|
43
|
-
def run(self, cmd: str, end_flag=None, allow_fold=True, max_fold_lines=4, strip_echo=True, stream=False, verbose=True, overall_timeout=None, response_timeout=None):
|
|
44
|
+
def run(self, cmd: str, end_flag=None, allow_fold=True, max_fold_lines=4, strip_echo=True, stream=False, verbose=True, overall_timeout=None, response_timeout=None, keep_type=False):
|
|
44
45
|
# 先清理残留输出,避免前一次命令的尾巴影响本次结果。
|
|
45
46
|
assert cmd is not None
|
|
46
47
|
self._core.drain_buffer()
|
|
@@ -73,12 +74,15 @@ class SerialNotebook():
|
|
|
73
74
|
):
|
|
74
75
|
_check_overall_timeout()
|
|
75
76
|
line: SerialLine
|
|
76
|
-
if line.kind == 'timeout':
|
|
77
|
+
if line.kind == 'error' and line.text == '<sdev timeout>\n':
|
|
77
78
|
raise TimeoutError
|
|
78
79
|
# print(line.kind, line.line, show_log, isinstance(line, SerialLine), line.kind == 'body')
|
|
79
80
|
if show_log and isinstance(line, SerialLine) and line.kind == 'body':
|
|
80
81
|
self._write_raw(f"{GREY}{line.text}{RESET}")
|
|
81
|
-
|
|
82
|
+
if keep_type:
|
|
83
|
+
yield line
|
|
84
|
+
else:
|
|
85
|
+
yield line.text
|
|
82
86
|
# 整个命令结束后,如启用 display,则额外输出一个空行(两个换行),
|
|
83
87
|
# 便于与后续命令在视觉上明显隔离。
|
|
84
88
|
if show_log:
|
|
@@ -137,12 +141,27 @@ class SerialNotebook():
|
|
|
137
141
|
self.run(cmd="reboot", end_flag=reboot_finish_when_see)
|
|
138
142
|
|
|
139
143
|
|
|
140
|
-
def get_model_type(self, prompt_flag=
|
|
144
|
+
def get_model_type(self, prompt_flag=None, timeout=3.0, verbose=False):
|
|
145
|
+
"""
|
|
146
|
+
读取设备树型号前缀:
|
|
147
|
+
- 命令:cat /proc/device-tree/model 2>/dev/null; echo
|
|
148
|
+
- 若 prompt_flag 为 None,则尝试使用较宽泛的匹配。
|
|
149
|
+
"""
|
|
150
|
+
if prompt_flag is None:
|
|
151
|
+
# 常见提示符结尾:#, $, >
|
|
152
|
+
prompt_flag = "[#$>]"
|
|
141
153
|
try:
|
|
142
154
|
cmd = "cat /proc/device-tree/model 2>/dev/null; echo"
|
|
143
|
-
lines = self.run(
|
|
144
|
-
|
|
145
|
-
|
|
155
|
+
lines = self.run(
|
|
156
|
+
cmd=cmd,
|
|
157
|
+
end_flag=prompt_flag,
|
|
158
|
+
stream=False,
|
|
159
|
+
overall_timeout=timeout,
|
|
160
|
+
response_timeout=timeout,
|
|
161
|
+
verbose=verbose,
|
|
162
|
+
)
|
|
163
|
+
if lines and len(lines) >= 2:
|
|
164
|
+
return lines[-2].strip()
|
|
146
165
|
except Exception:
|
|
147
166
|
pass
|
|
148
167
|
return None
|
|
@@ -105,9 +105,17 @@ def _content_contains_ctrl_c(text: str) -> bool:
|
|
|
105
105
|
return CTRL_C in n or "^C" in n
|
|
106
106
|
|
|
107
107
|
def scan_serial_ports() -> list[str]:
|
|
108
|
-
"""
|
|
108
|
+
"""扫描系统可用串口,并按平台做基础筛选。
|
|
109
|
+
|
|
110
|
+
- POSIX:仅保留以 /dev/tty 开头的设备(忽略 /dev/cu.* 等非目标设备);
|
|
111
|
+
- 其他平台:直接返回 list_ports 结果。
|
|
112
|
+
"""
|
|
109
113
|
import serial.tools.list_ports
|
|
110
|
-
|
|
114
|
+
|
|
115
|
+
ports = [p.device for p in serial.tools.list_ports.comports()]
|
|
116
|
+
if os.name == "posix":
|
|
117
|
+
ports = [d for d in ports if d.startswith("/dev/tty")]
|
|
118
|
+
return ports
|
|
111
119
|
|
|
112
120
|
def check_port_alive_nonblock(port: str, baudrate: int = 115200, timeout: float = 1.0) -> dict:
|
|
113
121
|
"""快速探测串口是否可用(不阻塞)。"""
|
|
@@ -244,7 +252,7 @@ class SerialRW():
|
|
|
244
252
|
for line in self._flag_stopper(lines, flag, allow_fold, max_fold_lines):
|
|
245
253
|
yield SerialLine(kind='body', text=line)
|
|
246
254
|
except TimeoutError:
|
|
247
|
-
yield SerialLine(kind='
|
|
255
|
+
yield SerialLine(kind='error', text='<sdev timeout>\n')
|
|
248
256
|
|
|
249
257
|
def drain_buffer(self) -> None:
|
|
250
258
|
while True:
|
|
@@ -9,6 +9,8 @@ import time
|
|
|
9
9
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
10
10
|
from typing import Any, Generator, Optional, List, Tuple, Dict
|
|
11
11
|
|
|
12
|
+
from loguru import logger
|
|
13
|
+
|
|
12
14
|
from .serial_rw import SerialRW, SerialLine, scan_serial_ports, check_port_alive_nonblock
|
|
13
15
|
|
|
14
16
|
DEFAULT_SERVICE_PORT = 7000
|
|
@@ -218,6 +220,7 @@ class SerialRWServer:
|
|
|
218
220
|
if isinstance(init, dict):
|
|
219
221
|
cmd = init.get("cmd")
|
|
220
222
|
if cmd == "list":
|
|
223
|
+
logger.info(f"[server] list request from {addr}")
|
|
221
224
|
ports = scan_serial_ports()
|
|
222
225
|
boards: List[dict] = []
|
|
223
226
|
if ports:
|
|
@@ -226,14 +229,18 @@ class SerialRWServer:
|
|
|
226
229
|
with ThreadPoolExecutor(max_workers=min(8, len(ports))) as ex:
|
|
227
230
|
futs = {ex.submit(_probe, p): p for p in ports}
|
|
228
231
|
for fut in as_completed(futs):
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
232
|
+
try:
|
|
233
|
+
r = fut.result()
|
|
234
|
+
boards.append({
|
|
235
|
+
"port": r["port"],
|
|
236
|
+
"baudrate": r["baudrate"],
|
|
237
|
+
"available": r["available"],
|
|
238
|
+
"reason": r.get("reason", ""),
|
|
239
|
+
})
|
|
240
|
+
except Exception as e:
|
|
241
|
+
logger.error(f"[server] probe error for port: {e}")
|
|
236
242
|
conn.sendall(json.dumps({"boards": boards}, ensure_ascii=False).encode("utf-8") + b"\n")
|
|
243
|
+
logger.info(f"[server] list response sent to {addr} (count={len(boards)})")
|
|
237
244
|
return
|
|
238
245
|
if cmd == "probe":
|
|
239
246
|
port = (init.get("device") or init.get("port") or "").strip()
|
|
@@ -251,10 +258,13 @@ class SerialRWServer:
|
|
|
251
258
|
conn.sendall(b'{"ok":false,"error":"missing device"}\n')
|
|
252
259
|
return
|
|
253
260
|
|
|
261
|
+
logger.info(f"[server] session from {addr}: port={device_val}, baudrate={baudrate_val}")
|
|
254
262
|
dev = SerialRW(device_val, baudrate_val)
|
|
255
263
|
try:
|
|
256
264
|
dev.connect()
|
|
265
|
+
logger.info(f"[server] connected to {device_val} for {addr}")
|
|
257
266
|
except Exception as e:
|
|
267
|
+
logger.error(f"[server] connect failed for {device_val}: {e}")
|
|
258
268
|
err = json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False).encode("utf-8") + b"\n"
|
|
259
269
|
conn.sendall(err)
|
|
260
270
|
return
|
|
@@ -340,6 +350,7 @@ class SerialRWServer:
|
|
|
340
350
|
|
|
341
351
|
while True:
|
|
342
352
|
conn, addr = sock.accept()
|
|
353
|
+
logger.info(f"[server] TCP connection from {addr}")
|
|
343
354
|
threading.Thread(target=self._session_loop, args=(conn, addr), daemon=True).start()
|
|
344
355
|
|
|
345
356
|
@staticmethod
|
|
@@ -401,17 +412,51 @@ def discover_hosts_via_zeroconf(timeout: float = 0.8) -> List[Tuple[str, int]]:
|
|
|
401
412
|
return []
|
|
402
413
|
|
|
403
414
|
def discover_hosts_via_udp(timeout: float = 1.5) -> List[Tuple[str, int]]:
|
|
415
|
+
"""
|
|
416
|
+
通过 UDP 广播 / 定向探测发现远程 sdev server:
|
|
417
|
+
- 优先读取环境变量 SDEV_DISCOVERY_TARGETS="host[:port],host2[:port2],..."
|
|
418
|
+
按指定目标发送 discovery 报文;
|
|
419
|
+
- 未指定时,退回到对 255.255.255.255:DISCOVERY_UDP_PORT 的广播。
|
|
420
|
+
"""
|
|
404
421
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
405
422
|
try:
|
|
406
423
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
407
424
|
sock.settimeout(timeout)
|
|
408
425
|
sock.bind(("", 0))
|
|
409
|
-
|
|
426
|
+
|
|
427
|
+
# 解析 SDEV_DISCOVERY_TARGETS 作为定向探测目标
|
|
428
|
+
targets: List[Tuple[str, int]] = []
|
|
429
|
+
raw = os.environ.get("SDEV_DISCOVERY_TARGETS", "").strip()
|
|
430
|
+
if raw:
|
|
431
|
+
for item in raw.split(","):
|
|
432
|
+
item = item.strip()
|
|
433
|
+
if not item:
|
|
434
|
+
continue
|
|
435
|
+
if ":" in item:
|
|
436
|
+
h, ps = item.rsplit(":", 1)
|
|
437
|
+
try:
|
|
438
|
+
targets.append((h.strip(), int(ps)))
|
|
439
|
+
except ValueError:
|
|
440
|
+
targets.append((h.strip(), DISCOVERY_UDP_PORT))
|
|
441
|
+
else:
|
|
442
|
+
targets.append((item, DISCOVERY_UDP_PORT))
|
|
443
|
+
if not targets:
|
|
444
|
+
targets.append(("255.255.255.255", DISCOVERY_UDP_PORT))
|
|
445
|
+
|
|
446
|
+
for host, port in targets:
|
|
447
|
+
try:
|
|
448
|
+
sock.sendto(b"SDEV_DISCOVERY", (host, port))
|
|
449
|
+
except OSError:
|
|
450
|
+
continue
|
|
451
|
+
|
|
410
452
|
hosts: set = set()
|
|
411
453
|
deadline = time.monotonic() + timeout
|
|
412
454
|
while time.monotonic() < deadline:
|
|
413
455
|
try:
|
|
456
|
+
sock.settimeout(max(0.0, deadline - time.monotonic()))
|
|
414
457
|
data, addr = sock.recvfrom(4096)
|
|
458
|
+
if not data:
|
|
459
|
+
continue
|
|
415
460
|
obj = json.loads(data.decode("utf-8"))
|
|
416
461
|
if obj.get("magic") == "sdev-remote-v1":
|
|
417
462
|
hosts.add((addr[0], int(obj.get("port") or DEFAULT_SERVICE_PORT)))
|
|
@@ -454,13 +499,46 @@ def get_remote_hosts(service_port: int = DEFAULT_SERVICE_PORT) -> List[Tuple[str
|
|
|
454
499
|
return list(merged)
|
|
455
500
|
|
|
456
501
|
|
|
457
|
-
|
|
502
|
+
def _server_log_path() -> str:
|
|
503
|
+
"""
|
|
504
|
+
与 CLI `_server_log_path` 保持一致:
|
|
505
|
+
- 优先使用 XDG_RUNTIME_DIR/sdev/server.log
|
|
506
|
+
- 否则使用 ~/.cache/sdev/server.log
|
|
507
|
+
"""
|
|
508
|
+
base = os.environ.get("XDG_RUNTIME_DIR") or os.path.join(os.path.expanduser("~"), ".cache")
|
|
509
|
+
d = os.path.join(base, "sdev")
|
|
510
|
+
os.makedirs(d, mode=0o700, exist_ok=True)
|
|
511
|
+
return os.path.join(d, "server.log")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def main() -> int:
|
|
458
515
|
import sys
|
|
516
|
+
|
|
459
517
|
port = DEFAULT_SERVICE_PORT
|
|
460
518
|
if len(sys.argv) > 1:
|
|
461
519
|
try:
|
|
462
520
|
port = int(sys.argv[1])
|
|
463
521
|
except ValueError:
|
|
464
522
|
pass
|
|
523
|
+
|
|
524
|
+
# 与旧实现保持一致的日志行为:只写到 server.log,不打印到前台。
|
|
525
|
+
log_path = _server_log_path()
|
|
526
|
+
logger.remove()
|
|
527
|
+
logger.add(
|
|
528
|
+
log_path,
|
|
529
|
+
rotation="1 MB",
|
|
530
|
+
retention=5,
|
|
531
|
+
encoding="utf-8",
|
|
532
|
+
enqueue=True,
|
|
533
|
+
backtrace=False,
|
|
534
|
+
diagnose=False,
|
|
535
|
+
)
|
|
536
|
+
logger.info(f"[server] listening TCP 0.0.0.0:{port}, UDP 0.0.0.0:{DISCOVERY_UDP_PORT}")
|
|
537
|
+
|
|
465
538
|
server = SerialRWServer(port=port)
|
|
466
539
|
server.serve_forever()
|
|
540
|
+
return 0
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
if __name__ == "__main__":
|
|
544
|
+
raise SystemExit(main())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sdev
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.5
|
|
4
4
|
Summary: 串口控制器工具包
|
|
5
5
|
Home-page: https://github.com/klrc/sdev
|
|
6
6
|
Author: klrc
|
|
@@ -57,34 +57,41 @@ pip install sdev
|
|
|
57
57
|
|
|
58
58
|
## 命令行快速上手
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
-
|
|
63
|
-
- `
|
|
64
|
-
- `
|
|
60
|
+
安装后提供一个 `sdev` 命令,核心功能包括:
|
|
61
|
+
|
|
62
|
+
- **`sdev list [scope] [filters...]`**:扫描本地 / 远程可用串口。
|
|
63
|
+
- `scope`: `local`, `remote` 或不填。
|
|
64
|
+
- `filters`: 如 `type=XC01` 或 `host=192.168.1.101`。
|
|
65
|
+
- `-f, --fast`: 快速模式,找到第一个符合条件的设备后立即输出(适合脚本)。
|
|
66
|
+
- **`sdev run [cmd...]`**:执行命令。
|
|
67
|
+
- `-i, --interrupt-only`: 仅发送一次 Ctrl-C,随后被动收集输出(不发送新命令)。
|
|
68
|
+
- `-t, --timeout`: 整体超时限制。
|
|
69
|
+
- `-w, --wait-forever`: 永久等待输出,直至整体超时或手动中断。
|
|
70
|
+
- **`sdev server [action]`**:管理本机串口共享服务。
|
|
71
|
+
- `action`: `start`, `stop`, `restart`, `status`。
|
|
72
|
+
- **`sdev mcp [action]`**:AI 集成(Model Context Protocol)。
|
|
73
|
+
- `action`: `install` (一键配置到 IDE), `run` (由 IDE 调起的后台服务)。
|
|
65
74
|
- `sdev set` / `sdev show` / `sdev reset`:管理默认设备与缓存。
|
|
66
75
|
|
|
67
76
|
典型流程:
|
|
68
77
|
|
|
69
78
|
```bash
|
|
70
|
-
# 1.
|
|
79
|
+
# 1. 在“串口所在机器”上启动 server
|
|
71
80
|
sdev server start
|
|
72
81
|
|
|
73
|
-
# 2.
|
|
74
|
-
sdev list
|
|
82
|
+
# 2. 扫描并筛选可用设备
|
|
83
|
+
sdev list remote # 仅扫描远程
|
|
84
|
+
sdev list type=XC01 # 仅列出特定型号
|
|
85
|
+
sdev list -f type=XC01 # 快速模式:找到第一个 XC01 立即返回(适合脚本)
|
|
75
86
|
|
|
76
|
-
# 3.
|
|
87
|
+
# 3. 选中一块板,配置为默认设备
|
|
77
88
|
sdev set default <DEVICE_ID>
|
|
78
89
|
|
|
79
|
-
# 4.
|
|
80
|
-
sdev run 'cat /proc/meminfo'
|
|
81
|
-
sdev run -
|
|
90
|
+
# 4. 执行命令
|
|
91
|
+
sdev run 'cat /proc/meminfo' # 执行完整交互
|
|
92
|
+
sdev run -i # 仅发送 Ctrl-C (用于中断正在跑的任务并查看输出)
|
|
82
93
|
```
|
|
83
94
|
|
|
84
|
-
更详细的 CLI 用法与行为说明,参考项目内的 skill 文档:
|
|
85
|
-
|
|
86
|
-
- `docs/skills/serial-sdev-cli/SKILL.md`
|
|
87
|
-
|
|
88
95
|
---
|
|
89
96
|
|
|
90
97
|
## Python API 快速上手(SerialNotebook)
|
|
@@ -125,6 +132,25 @@ with SerialNotebook(device=SERIAL_PORT, baudrate=115200, host=HOST, port=7000) a
|
|
|
125
132
|
|
|
126
133
|
---
|
|
127
134
|
|
|
135
|
+
## AI 集成 (MCP)
|
|
136
|
+
|
|
137
|
+
`sdev` 支持 **Model Context Protocol (MCP)**,允许 AI 助手(如 Cursor、Claude Desktop)直接操作串口,执行命令并感知设备状态。
|
|
138
|
+
|
|
139
|
+
### 1. 获取配置信息
|
|
140
|
+
在本地环境执行:
|
|
141
|
+
```bash
|
|
142
|
+
sdev mcp install
|
|
143
|
+
```
|
|
144
|
+
该命令会自动生成可用于粘贴的 JSON 配置片段,并提示 Cursor 或 Claude Desktop 的配置路径。
|
|
145
|
+
|
|
146
|
+
### 2. 提供的能力 (Tools)
|
|
147
|
+
安装后,AI 助手将获得以下工具:
|
|
148
|
+
- `sdev_list`: 获取本地及远程可用设备列表。
|
|
149
|
+
- `sdev_run`: 在指定设备上执行命令并获取输出。
|
|
150
|
+
- `sdev_set_default`: 设置当前会话的默认设备。
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
128
154
|
## 适用 / 不适用的场景
|
|
129
155
|
|
|
130
156
|
**适用:**
|
|
@@ -143,9 +169,6 @@ with SerialNotebook(device=SERIAL_PORT, baudrate=115200, host=HOST, port=7000) a
|
|
|
143
169
|
## 更多文档
|
|
144
170
|
|
|
145
171
|
- **内部架构与设计说明**:`docs/arch_design.md`
|
|
146
|
-
- **技能(在 Cursor 里集成使用方式)**:
|
|
147
|
-
- `docs/skills/serial-sdev-cli/SKILL.md`
|
|
148
|
-
- `docs/skills/serial-sdev-api/SKILL.md`
|
|
149
172
|
|
|
150
173
|
源码结构概览(仅列出现行实现):
|
|
151
174
|
|
|
@@ -153,5 +176,6 @@ with SerialNotebook(device=SERIAL_PORT, baudrate=115200, host=HOST, port=7000) a
|
|
|
153
176
|
- `serial_rw.py`:本地串口读写与锁管理(`SerialRW`)、运行控制器(`SerialRunner`)、端口扫描与快速探测;
|
|
154
177
|
- `serial_rw_server.py`:远程串口共享服务与客户端(`SerialRWServer` / `SerialRWClient`),以及主机发现逻辑;
|
|
155
178
|
- `serial_notebook.py`:Notebook / 脚本场景下的最高层 Python 入口(`SerialNotebook`),基于 `SerialRW` / `SerialRWClient` 实现。
|
|
156
|
-
- `sdev/cli/`:命令行入口 `sdev`(`__init__.py` 中的 `main()
|
|
179
|
+
- `sdev/cli/`:命令行入口 `sdev`(`__init__.py` 中的 `main()`),以 `SerialNotebook` 作为上层统一入口,并通过 `SerialRWServer`/`SerialRWClient` 实现远程 `run / list / server` 等子命令。
|
|
180
|
+
|
|
157
181
|
|
|
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
|
|
|
5
5
|
|
|
6
6
|
setup(
|
|
7
7
|
name="sdev",
|
|
8
|
-
version="0.7.
|
|
8
|
+
version="0.7.5",
|
|
9
9
|
author="klrc",
|
|
10
10
|
author_email="144069824@qq.com",
|
|
11
11
|
description="串口控制器工具包",
|
|
@@ -32,6 +32,7 @@ setup(
|
|
|
32
32
|
"pyserial>=3.5",
|
|
33
33
|
"loguru>=0.6.0",
|
|
34
34
|
"zeroconf>=0.39.0",
|
|
35
|
+
"mcp>=1.0.0",
|
|
35
36
|
],
|
|
36
37
|
entry_points={
|
|
37
38
|
"console_scripts": ["sdev=sdev.cli:main"],
|
|
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
|