seeed-jetson-developer 0.1.0__py3-none-any.whl
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.
- seeed_jetson_develop/__init__.py +7 -0
- seeed_jetson_develop/assets/recovery/1.png +0 -0
- seeed_jetson_develop/assets/recovery/4.jpg +0 -0
- seeed_jetson_develop/assets/recovery/97.png +0 -0
- seeed_jetson_develop/assets/recovery/button.jpg +0 -0
- seeed_jetson_develop/assets/recovery/classic_fc_rec_2.png +0 -0
- seeed_jetson_develop/assets/recovery/flash.jpg +0 -0
- seeed_jetson_develop/assets/recovery/flash1.jpg +0 -0
- seeed_jetson_develop/assets/recovery/flash_1.png +0 -0
- seeed_jetson_develop/assets/recovery/industrial_rec_2.png +0 -0
- seeed_jetson_develop/assets/recovery/mini_lsusb_3.png +0 -0
- seeed_jetson_develop/assets/recovery/reComputer_mini_rec.png +0 -0
- seeed_jetson_develop/assets/recovery/reset.png +0 -0
- seeed_jetson_develop/assets/recovery/robotics_lsusb_f.png +0 -0
- seeed_jetson_develop/cli.py +113 -0
- seeed_jetson_develop/core/__init__.py +3 -0
- seeed_jetson_develop/core/config.py +45 -0
- seeed_jetson_develop/core/device.py +14 -0
- seeed_jetson_develop/core/events.py +28 -0
- seeed_jetson_develop/core/platform_detect.py +27 -0
- seeed_jetson_develop/core/runner.py +284 -0
- seeed_jetson_develop/data/l4t_data.json +844 -0
- seeed_jetson_develop/data/product_images.json +226 -0
- seeed_jetson_develop/data/recovery_guides.json +222 -0
- seeed_jetson_develop/data/recovery_guides.py +320 -0
- seeed_jetson_develop/flash.py +516 -0
- seeed_jetson_develop/gui/__init__.py +14 -0
- seeed_jetson_develop/gui/ai_chat.py +877 -0
- seeed_jetson_develop/gui/flash_animation.py +315 -0
- seeed_jetson_develop/gui/main_window.py +473 -0
- seeed_jetson_develop/gui/main_window_modern.py +718 -0
- seeed_jetson_develop/gui/main_window_sdk.py +1320 -0
- seeed_jetson_develop/gui/main_window_v2.py +2997 -0
- seeed_jetson_develop/gui/runtime_i18n.py +636 -0
- seeed_jetson_develop/gui/styles.py +482 -0
- seeed_jetson_develop/gui/theme.py +958 -0
- seeed_jetson_develop/modules/__init__.py +0 -0
- seeed_jetson_develop/modules/apps/__init__.py +2 -0
- seeed_jetson_develop/modules/apps/data/apps.json +102 -0
- seeed_jetson_develop/modules/apps/data/jetson_examples.json +632 -0
- seeed_jetson_develop/modules/apps/page.py +792 -0
- seeed_jetson_develop/modules/apps/registry.py +84 -0
- seeed_jetson_develop/modules/community/__init__.py +2 -0
- seeed_jetson_develop/modules/community/page.py +14 -0
- seeed_jetson_develop/modules/devices/__init__.py +2 -0
- seeed_jetson_develop/modules/devices/diagnostics.py +244 -0
- seeed_jetson_develop/modules/devices/page.py +595 -0
- seeed_jetson_develop/modules/flash/__init__.py +2 -0
- seeed_jetson_develop/modules/flash/page.py +16 -0
- seeed_jetson_develop/modules/flash/thread.py +81 -0
- seeed_jetson_develop/modules/remote/__init__.py +2 -0
- seeed_jetson_develop/modules/remote/agent_install_dialog.py +317 -0
- seeed_jetson_develop/modules/remote/connector.py +94 -0
- seeed_jetson_develop/modules/remote/desktop_dialog.py +336 -0
- seeed_jetson_develop/modules/remote/desktop_remote.py +223 -0
- seeed_jetson_develop/modules/remote/jetson_init.py +1243 -0
- seeed_jetson_develop/modules/remote/native_terminal.py +197 -0
- seeed_jetson_develop/modules/remote/net_share.py +384 -0
- seeed_jetson_develop/modules/remote/net_share_dialog.py +475 -0
- seeed_jetson_develop/modules/remote/page.py +1324 -0
- seeed_jetson_develop/modules/skills/__init__.py +3 -0
- seeed_jetson_develop/modules/skills/data/skills.json +442 -0
- seeed_jetson_develop/modules/skills/engine.py +636 -0
- seeed_jetson_develop/modules/skills/page.py +967 -0
- seeed_jetson_develop/recovery.py +89 -0
- seeed_jetson_developer-0.1.0.dist-info/METADATA +170 -0
- seeed_jetson_developer-0.1.0.dist-info/RECORD +71 -0
- seeed_jetson_developer-0.1.0.dist-info/WHEEL +5 -0
- seeed_jetson_developer-0.1.0.dist-info/entry_points.txt +2 -0
- seeed_jetson_developer-0.1.0.dist-info/licenses/LICENSE +21 -0
- seeed_jetson_developer-0.1.0.dist-info/top_level.txt +1 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
命令行接口模块
|
|
4
|
+
"""
|
|
5
|
+
import click
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from .flash import JetsonFlasher
|
|
10
|
+
from .recovery import RecoveryGuide
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.version_option()
|
|
15
|
+
def cli():
|
|
16
|
+
"""Seeed Jetson Flash - 为 Jetson 设备刷机的工具"""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@cli.command()
|
|
21
|
+
@click.option('--product', '-p', required=True, help='产品型号 (例如: j4012mini)')
|
|
22
|
+
@click.option('--l4t', '-l', required=True, help='L4T 版本 (例如: 36.3.0)')
|
|
23
|
+
@click.option('--download-only', is_flag=True, help='仅下载固件,不刷写')
|
|
24
|
+
@click.option('--skip-verify', is_flag=True, help='跳过 SHA256 校验')
|
|
25
|
+
def flash(product, l4t, download_only, skip_verify):
|
|
26
|
+
"""刷写 Jetson 设备"""
|
|
27
|
+
flasher = JetsonFlasher(product, l4t)
|
|
28
|
+
|
|
29
|
+
click.echo(f"正在为 {product} 准备 L4T {l4t} 固件...")
|
|
30
|
+
|
|
31
|
+
# 下载固件
|
|
32
|
+
if not flasher.download_firmware():
|
|
33
|
+
click.echo("固件下载失败", err=True)
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
# 校验固件
|
|
37
|
+
if not skip_verify:
|
|
38
|
+
if not flasher.verify_firmware():
|
|
39
|
+
click.echo("固件校验失败", err=True)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
if download_only:
|
|
43
|
+
click.echo("固件下载完成")
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
# 解压固件
|
|
47
|
+
if not flasher.extract_firmware():
|
|
48
|
+
click.echo("固件解压失败", err=True)
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
# 刷写固件
|
|
52
|
+
if not flasher.flash_firmware():
|
|
53
|
+
click.echo("固件刷写失败", err=True)
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
click.echo("刷写完成!")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@cli.command()
|
|
60
|
+
@click.option('--product', '-p', required=True, help='产品型号')
|
|
61
|
+
def recovery(product):
|
|
62
|
+
"""显示进入 Recovery 模式的教程"""
|
|
63
|
+
guide = RecoveryGuide(product)
|
|
64
|
+
guide.show_guide()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@cli.command()
|
|
68
|
+
def list_products():
|
|
69
|
+
"""列出所有支持的产品"""
|
|
70
|
+
data_path = Path(__file__).parent / "data" / "l4t_data.json"
|
|
71
|
+
with open(data_path, 'r') as f:
|
|
72
|
+
data = json.load(f)
|
|
73
|
+
|
|
74
|
+
products = {}
|
|
75
|
+
for item in data:
|
|
76
|
+
product = item['product']
|
|
77
|
+
if product not in products:
|
|
78
|
+
products[product] = []
|
|
79
|
+
products[product].append(item['l4t'])
|
|
80
|
+
|
|
81
|
+
click.echo("支持的产品列表:\n")
|
|
82
|
+
for product, l4t_versions in sorted(products.items()):
|
|
83
|
+
click.echo(f" {product}")
|
|
84
|
+
click.echo(f" L4T 版本: {', '.join(l4t_versions)}")
|
|
85
|
+
click.echo()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@cli.command()
|
|
89
|
+
def gui():
|
|
90
|
+
"""启动图形界面"""
|
|
91
|
+
try:
|
|
92
|
+
# 直接导入 main 函数,避免通过 __init__.py
|
|
93
|
+
import sys
|
|
94
|
+
from pathlib import Path
|
|
95
|
+
|
|
96
|
+
# 确保可以找到模块
|
|
97
|
+
module_path = Path(__file__).parent
|
|
98
|
+
if str(module_path) not in sys.path:
|
|
99
|
+
sys.path.insert(0, str(module_path))
|
|
100
|
+
|
|
101
|
+
from seeed_jetson_develop.gui.main_window import main as gui_main
|
|
102
|
+
gui_main()
|
|
103
|
+
except ImportError as e:
|
|
104
|
+
click.echo(f"错误: 无法启动 GUI,请安装 PyQt5: pip install PyQt5", err=True)
|
|
105
|
+
click.echo(f"详细错误: {e}", err=True)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main():
|
|
109
|
+
cli()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == '__main__':
|
|
113
|
+
main()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""全局配置持久化"""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_CONFIG_PATH = Path.home() / ".config" / "seeed-jetson-tool" / "config.json"
|
|
7
|
+
DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load() -> dict:
|
|
11
|
+
try:
|
|
12
|
+
return json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
|
|
13
|
+
except Exception:
|
|
14
|
+
return {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def save(data: dict):
|
|
18
|
+
_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
_CONFIG_PATH.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_runtime_anthropic_settings() -> dict:
|
|
23
|
+
data = load()
|
|
24
|
+
|
|
25
|
+
config_key = (data.get("anthropic_api_key") or "").strip()
|
|
26
|
+
env_key = (os.environ.get("ANTHROPIC_API_KEY") or "").strip()
|
|
27
|
+
api_key = config_key or env_key
|
|
28
|
+
api_key_source = "config" if config_key else ("env" if env_key else "none")
|
|
29
|
+
|
|
30
|
+
config_url = (data.get("anthropic_base_url") or "").strip()
|
|
31
|
+
env_url = (os.environ.get("ANTHROPIC_BASE_URL") or "").strip()
|
|
32
|
+
base_url = config_url or env_url or DEFAULT_ANTHROPIC_BASE_URL
|
|
33
|
+
if config_url:
|
|
34
|
+
base_url_source = "config"
|
|
35
|
+
elif env_url:
|
|
36
|
+
base_url_source = "env"
|
|
37
|
+
else:
|
|
38
|
+
base_url_source = "default"
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
"api_key": api_key,
|
|
42
|
+
"api_key_source": api_key_source,
|
|
43
|
+
"base_url": base_url,
|
|
44
|
+
"base_url_source": base_url_source,
|
|
45
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""设备信息数据类"""
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class DeviceInfo:
|
|
8
|
+
ip: str = ""
|
|
9
|
+
hostname: str = ""
|
|
10
|
+
model: str = "" # e.g. "reComputer J4012"
|
|
11
|
+
jetpack: str = "" # e.g. "6.0"
|
|
12
|
+
l4t: str = "" # e.g. "36.3.0"
|
|
13
|
+
connected: bool = False
|
|
14
|
+
diagnostics: dict = field(default_factory=dict)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""全局事件总线 — 模块间通信,避免直接 import 耦合"""
|
|
2
|
+
from PyQt5.QtCore import QObject, pyqtSignal
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class EventBus(QObject):
|
|
6
|
+
# devices 模块
|
|
7
|
+
device_connected = pyqtSignal(dict) # payload: {ip, name, model}
|
|
8
|
+
device_disconnected = pyqtSignal(str) # payload: ip
|
|
9
|
+
diagnostics_done = pyqtSignal(dict) # payload: {item: status}
|
|
10
|
+
|
|
11
|
+
# flash 模块
|
|
12
|
+
flash_started = pyqtSignal(str, str) # product, l4t
|
|
13
|
+
flash_completed = pyqtSignal(bool, str) # success, message
|
|
14
|
+
|
|
15
|
+
# skills 模块
|
|
16
|
+
skill_run_requested = pyqtSignal(str) # skill_id
|
|
17
|
+
skill_completed = pyqtSignal(str, bool, str) # skill_id, success, log
|
|
18
|
+
|
|
19
|
+
# apps 模块
|
|
20
|
+
app_install_requested = pyqtSignal(str) # app_id
|
|
21
|
+
app_installed = pyqtSignal(str, bool) # app_id, success
|
|
22
|
+
|
|
23
|
+
# 导航
|
|
24
|
+
navigate_to = pyqtSignal(int) # page index
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# 全局单例,所有模块 from seeed_jetson_develop.core import bus
|
|
28
|
+
bus = EventBus()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""运行环境检测 — 判断是否在 Jetson 设备本地运行"""
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def is_jetson() -> bool:
|
|
7
|
+
"""
|
|
8
|
+
检测当前是否运行在 NVIDIA Jetson 设备上。
|
|
9
|
+
判断依据:Linux + aarch64 架构 + Tegra 特有文件。
|
|
10
|
+
"""
|
|
11
|
+
if platform.system() != "Linux":
|
|
12
|
+
return False
|
|
13
|
+
if platform.machine() not in ("aarch64", "armv8l"):
|
|
14
|
+
return False
|
|
15
|
+
# Tegra release 标志文件
|
|
16
|
+
if os.path.exists("/etc/nv_tegra_release"):
|
|
17
|
+
return True
|
|
18
|
+
# 设备树 model 文件
|
|
19
|
+
model_file = "/proc/device-tree/model"
|
|
20
|
+
if os.path.exists(model_file):
|
|
21
|
+
try:
|
|
22
|
+
with open(model_file, "rb") as f:
|
|
23
|
+
model = f.read().decode("utf-8", errors="ignore").lower()
|
|
24
|
+
return "jetson" in model or "tegra" in model
|
|
25
|
+
except Exception:
|
|
26
|
+
pass
|
|
27
|
+
return False
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""命令执行引擎 — 本地或远程 SSH 执行,统一接口"""
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import shlex
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import Callable, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Runner:
|
|
10
|
+
"""
|
|
11
|
+
本地命令执行器。
|
|
12
|
+
后续可扩展 SSHRunner(Runner) 实现远程执行,接口保持一致。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def run(
|
|
16
|
+
self,
|
|
17
|
+
cmd: str,
|
|
18
|
+
timeout: int = 30,
|
|
19
|
+
on_output: Optional[Callable[[str], None]] = None,
|
|
20
|
+
) -> tuple[int, str]:
|
|
21
|
+
"""
|
|
22
|
+
执行命令,返回 (returncode, output)。
|
|
23
|
+
on_output: 实时输出回调,每行调用一次。
|
|
24
|
+
同时处理 \\n 和 \\r 分隔(支持 pip 进度条等 \\r 刷新场景)。
|
|
25
|
+
"""
|
|
26
|
+
env = {**os.environ, "PYTHONUNBUFFERED": "1"}
|
|
27
|
+
try:
|
|
28
|
+
proc = subprocess.Popen(
|
|
29
|
+
cmd, shell=True,
|
|
30
|
+
stdout=subprocess.PIPE,
|
|
31
|
+
stderr=subprocess.STDOUT,
|
|
32
|
+
env=env,
|
|
33
|
+
)
|
|
34
|
+
fd = proc.stdout.fileno()
|
|
35
|
+
buf = b""
|
|
36
|
+
lines = []
|
|
37
|
+
|
|
38
|
+
while True:
|
|
39
|
+
try:
|
|
40
|
+
chunk = os.read(fd, 65536) # 有多少读多少,立即返回
|
|
41
|
+
except OSError:
|
|
42
|
+
break
|
|
43
|
+
if not chunk:
|
|
44
|
+
break
|
|
45
|
+
buf += chunk
|
|
46
|
+
# 按 \n 或 \r 切割,保留最后未完成的片段
|
|
47
|
+
parts = re.split(rb"[\n\r]+", buf)
|
|
48
|
+
for part in parts[:-1]:
|
|
49
|
+
line = part.decode("utf-8", errors="replace").strip()
|
|
50
|
+
if line:
|
|
51
|
+
lines.append(line)
|
|
52
|
+
if on_output:
|
|
53
|
+
on_output(line)
|
|
54
|
+
buf = parts[-1]
|
|
55
|
+
|
|
56
|
+
# 冲刷剩余缓冲
|
|
57
|
+
if buf.strip():
|
|
58
|
+
line = buf.decode("utf-8", errors="replace").strip()
|
|
59
|
+
lines.append(line)
|
|
60
|
+
if on_output:
|
|
61
|
+
on_output(line)
|
|
62
|
+
|
|
63
|
+
proc.wait(timeout=timeout)
|
|
64
|
+
return proc.returncode, "\n".join(lines)
|
|
65
|
+
except subprocess.TimeoutExpired:
|
|
66
|
+
proc.kill()
|
|
67
|
+
return -1, "timeout"
|
|
68
|
+
except Exception as e:
|
|
69
|
+
return -1, str(e)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SSHRunner(Runner):
|
|
73
|
+
"""
|
|
74
|
+
远程 SSH 命令执行器,接口与 Runner 一致。
|
|
75
|
+
每次 run() 建立一条独立 SSH 连接,适合诊断类低频调用。
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, host: str, username: str = "seeed",
|
|
79
|
+
password: str = "", port: int = 22, sudo_password: str = ""):
|
|
80
|
+
self.host = host
|
|
81
|
+
self.username = username
|
|
82
|
+
self.password = password
|
|
83
|
+
self.sudo_password = sudo_password or password
|
|
84
|
+
self.port = port
|
|
85
|
+
|
|
86
|
+
def _wrap_with_sudo_password(self, cmd: str) -> str:
|
|
87
|
+
wrapper_parts = ["export TERM=${TERM:-xterm-256color};"]
|
|
88
|
+
if self.sudo_password:
|
|
89
|
+
wrapper_parts.extend([
|
|
90
|
+
f"export SEEED_SUDO_PASSWORD={shlex.quote(self.sudo_password)};",
|
|
91
|
+
"sudo() { printf '%s\\n' \"$SEEED_SUDO_PASSWORD\" | command sudo -S \"$@\"; };",
|
|
92
|
+
"export -f sudo;",
|
|
93
|
+
])
|
|
94
|
+
wrapper_parts.append(cmd)
|
|
95
|
+
wrapper = " ".join(wrapper_parts)
|
|
96
|
+
return f"bash -lc {shlex.quote(wrapper)}"
|
|
97
|
+
|
|
98
|
+
def open_sftp(self):
|
|
99
|
+
"""建立 SSH 连接并返回 (client, sftp),调用方负责 client.close()。"""
|
|
100
|
+
import paramiko
|
|
101
|
+
client = paramiko.SSHClient()
|
|
102
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
103
|
+
client.connect(
|
|
104
|
+
self.host,
|
|
105
|
+
port=self.port,
|
|
106
|
+
username=self.username,
|
|
107
|
+
password=self.password or None,
|
|
108
|
+
timeout=10,
|
|
109
|
+
look_for_keys=True,
|
|
110
|
+
allow_agent=True,
|
|
111
|
+
)
|
|
112
|
+
client.get_transport().set_keepalive(30)
|
|
113
|
+
sftp = client.open_sftp()
|
|
114
|
+
return client, sftp
|
|
115
|
+
|
|
116
|
+
def run(
|
|
117
|
+
self,
|
|
118
|
+
cmd: str,
|
|
119
|
+
timeout: int = 30,
|
|
120
|
+
on_output: Optional[Callable[[str], None]] = None,
|
|
121
|
+
) -> tuple[int, str]:
|
|
122
|
+
try:
|
|
123
|
+
import paramiko
|
|
124
|
+
client = paramiko.SSHClient()
|
|
125
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
126
|
+
client.connect(
|
|
127
|
+
self.host,
|
|
128
|
+
port=self.port,
|
|
129
|
+
username=self.username,
|
|
130
|
+
password=self.password or None,
|
|
131
|
+
timeout=10,
|
|
132
|
+
look_for_keys=True,
|
|
133
|
+
allow_agent=True,
|
|
134
|
+
)
|
|
135
|
+
client.get_transport().set_keepalive(30)
|
|
136
|
+
try:
|
|
137
|
+
_, stdout, stderr = client.exec_command(
|
|
138
|
+
self._wrap_with_sudo_password(cmd),
|
|
139
|
+
timeout=timeout,
|
|
140
|
+
)
|
|
141
|
+
lines = []
|
|
142
|
+
for raw in stdout:
|
|
143
|
+
line = raw.rstrip("\n")
|
|
144
|
+
lines.append(line)
|
|
145
|
+
if on_output:
|
|
146
|
+
on_output(line)
|
|
147
|
+
rc = stdout.channel.recv_exit_status()
|
|
148
|
+
err = stderr.read().decode("utf-8", errors="replace").strip()
|
|
149
|
+
if err:
|
|
150
|
+
for line in err.splitlines():
|
|
151
|
+
if on_output:
|
|
152
|
+
on_output(line)
|
|
153
|
+
lines.extend(line for line in err.splitlines() if line)
|
|
154
|
+
out = "\n".join(lines)
|
|
155
|
+
return rc, out
|
|
156
|
+
finally:
|
|
157
|
+
client.close()
|
|
158
|
+
except Exception as e:
|
|
159
|
+
return -1, str(e)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class SerialRunner(Runner):
|
|
163
|
+
"""
|
|
164
|
+
通过串口登录 Jetson 并执行命令,接口与 SSHRunner 一致。
|
|
165
|
+
每次 run() 建立一次串口会话,适合诊断类低频调用。
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(self, port: str, username: str = "seeed", password: str = ""):
|
|
169
|
+
self.port = port
|
|
170
|
+
self.username = username
|
|
171
|
+
self.password = password
|
|
172
|
+
|
|
173
|
+
def run(
|
|
174
|
+
self,
|
|
175
|
+
cmd: str,
|
|
176
|
+
timeout: int = 30,
|
|
177
|
+
on_output: Optional[Callable[[str], None]] = None,
|
|
178
|
+
) -> tuple[int, str]:
|
|
179
|
+
import re
|
|
180
|
+
import time
|
|
181
|
+
try:
|
|
182
|
+
import serial
|
|
183
|
+
except ImportError:
|
|
184
|
+
return -1, "pyserial 未安装,请运行 pip install pyserial"
|
|
185
|
+
|
|
186
|
+
lines: list[str] = []
|
|
187
|
+
|
|
188
|
+
def _emit(text: str):
|
|
189
|
+
if on_output:
|
|
190
|
+
on_output(text)
|
|
191
|
+
lines.append(text)
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
ser = serial.Serial(
|
|
195
|
+
self.port, baudrate=115200, timeout=0.1,
|
|
196
|
+
bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE,
|
|
197
|
+
stopbits=serial.STOPBITS_ONE,
|
|
198
|
+
)
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
return -1, str(exc)
|
|
201
|
+
|
|
202
|
+
def _read_until(patterns: list[str], wait: float) -> str:
|
|
203
|
+
buf = ""
|
|
204
|
+
deadline = time.time() + wait
|
|
205
|
+
while time.time() < deadline:
|
|
206
|
+
if ser.in_waiting:
|
|
207
|
+
chunk = ser.read(ser.in_waiting).decode("utf-8", errors="ignore")
|
|
208
|
+
if chunk:
|
|
209
|
+
buf += chunk
|
|
210
|
+
else:
|
|
211
|
+
time.sleep(0.05)
|
|
212
|
+
for p in patterns:
|
|
213
|
+
if re.search(p, buf):
|
|
214
|
+
return buf
|
|
215
|
+
return buf
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
for _ in range(3):
|
|
219
|
+
ser.write(b"\r\n")
|
|
220
|
+
time.sleep(0.3)
|
|
221
|
+
buf = _read_until([r"login:", r"[$#]\s*"], 8.0)
|
|
222
|
+
|
|
223
|
+
if re.search(r"login:", buf):
|
|
224
|
+
time.sleep(0.2)
|
|
225
|
+
ser.write((self.username + "\r\n").encode())
|
|
226
|
+
buf = _read_until([r"[Pp]assword:", r"[$#]\s*"], 8.0)
|
|
227
|
+
|
|
228
|
+
if re.search(r"[Pp]assword:", buf):
|
|
229
|
+
time.sleep(0.2)
|
|
230
|
+
ser.write((self.password + "\r\n").encode())
|
|
231
|
+
buf = _read_until(
|
|
232
|
+
[r"[$#]\s*", r"[Ll]ogin incorrect", r"[Aa]uthentication failure"], 10.0)
|
|
233
|
+
|
|
234
|
+
if not re.search(r"[$#]\s*", buf):
|
|
235
|
+
if re.search(r"[Ll]ogin incorrect|[Aa]uthentication failure", buf):
|
|
236
|
+
return -1, "用户名或密码错误"
|
|
237
|
+
return -1, "登录失败,未检测到 shell 提示符"
|
|
238
|
+
|
|
239
|
+
time.sleep(0.2)
|
|
240
|
+
ser.write(b"export TERM=xterm-256color\r\n")
|
|
241
|
+
_read_until([r"[$#]\s*"], 3.0)
|
|
242
|
+
ser.write((cmd + "\r\n").encode())
|
|
243
|
+
result = _read_until([r"[$#]\s*"], float(timeout))
|
|
244
|
+
|
|
245
|
+
# 去掉 ANSI 转义码,提取命令输出
|
|
246
|
+
clean = re.sub(r"\x1B\[[0-?]*[ -/]*[@-~]", "", result)
|
|
247
|
+
clean = re.sub(r"\x1B[@-_]", "", clean)
|
|
248
|
+
# 去掉回显的命令行和最后的提示符
|
|
249
|
+
out_lines = []
|
|
250
|
+
skip_first = True
|
|
251
|
+
for line in clean.splitlines():
|
|
252
|
+
stripped = line.strip()
|
|
253
|
+
if skip_first and cmd.strip() in stripped:
|
|
254
|
+
skip_first = False
|
|
255
|
+
continue
|
|
256
|
+
if re.search(r"[$#]\s*$", stripped) and not stripped.replace("$", "").replace("#", "").strip():
|
|
257
|
+
continue
|
|
258
|
+
if stripped:
|
|
259
|
+
out_lines.append(stripped)
|
|
260
|
+
if on_output:
|
|
261
|
+
on_output(stripped)
|
|
262
|
+
return 0, "\n".join(out_lines)
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
return -1, str(exc)
|
|
265
|
+
finally:
|
|
266
|
+
try:
|
|
267
|
+
ser.close()
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ── 全局活跃 Runner 单例 ────────────────────────────────────────────────────
|
|
273
|
+
_active_runner: Optional[Runner] = None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def get_runner() -> Runner:
|
|
277
|
+
"""返回全局活跃 Runner(SSH 已连接则为 SSHRunner,否则为本地 Runner)。"""
|
|
278
|
+
return _active_runner if _active_runner is not None else Runner()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def set_runner(runner: Optional[Runner]) -> None:
|
|
282
|
+
"""切换全局 Runner。传 None 恢复本地模式。"""
|
|
283
|
+
global _active_runner
|
|
284
|
+
_active_runner = runner
|