sdev 0.5.2__tar.gz → 0.5.4__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.5.2/sdev.egg-info → sdev-0.5.4}/PKG-INFO +2 -1
- {sdev-0.5.2 → sdev-0.5.4}/pyproject.toml +2 -1
- {sdev-0.5.2 → sdev-0.5.4}/sdev/__init__.py +1 -1
- {sdev-0.5.2 → sdev-0.5.4}/sdev/cli/cli_wrapper.py +83 -31
- {sdev-0.5.2 → sdev-0.5.4}/sdev/remote/discovery.py +138 -3
- {sdev-0.5.2 → sdev-0.5.4}/sdev/remote/server.py +91 -1
- {sdev-0.5.2 → sdev-0.5.4/sdev.egg-info}/PKG-INFO +2 -1
- {sdev-0.5.2 → sdev-0.5.4}/sdev.egg-info/requires.txt +1 -0
- {sdev-0.5.2 → sdev-0.5.4}/setup.py +2 -1
- {sdev-0.5.2 → sdev-0.5.4}/LICENSE +0 -0
- {sdev-0.5.2 → sdev-0.5.4}/MANIFEST.in +0 -0
- {sdev-0.5.2 → sdev-0.5.4}/README.md +0 -0
- {sdev-0.5.2 → sdev-0.5.4}/sdev/base/__init__.py +0 -0
- {sdev-0.5.2 → sdev-0.5.4}/sdev/base/serial_core.py +0 -0
- {sdev-0.5.2 → sdev-0.5.4}/sdev/cli/__init__.py +0 -0
- {sdev-0.5.2 → sdev-0.5.4}/sdev/models/__init__.py +0 -0
- {sdev-0.5.2 → sdev-0.5.4}/sdev/models/demoboard.py +0 -0
- {sdev-0.5.2 → sdev-0.5.4}/sdev/remote/__init__.py +0 -0
- {sdev-0.5.2 → sdev-0.5.4}/sdev/remote/client.py +0 -0
- {sdev-0.5.2 → sdev-0.5.4}/sdev.egg-info/SOURCES.txt +0 -0
- {sdev-0.5.2 → sdev-0.5.4}/sdev.egg-info/dependency_links.txt +0 -0
- {sdev-0.5.2 → sdev-0.5.4}/sdev.egg-info/entry_points.txt +0 -0
- {sdev-0.5.2 → sdev-0.5.4}/sdev.egg-info/top_level.txt +0 -0
- {sdev-0.5.2 → sdev-0.5.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sdev
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.4
|
|
4
4
|
Summary: 串口控制器工具包
|
|
5
5
|
Home-page: https://github.com/klrc/sdev
|
|
6
6
|
Author: klrc
|
|
@@ -27,6 +27,7 @@ Description-Content-Type: text/markdown
|
|
|
27
27
|
License-File: LICENSE
|
|
28
28
|
Requires-Dist: pyserial>=3.5
|
|
29
29
|
Requires-Dist: loguru>=0.6.0
|
|
30
|
+
Requires-Dist: zeroconf>=0.39.0
|
|
30
31
|
Dynamic: author
|
|
31
32
|
Dynamic: home-page
|
|
32
33
|
Dynamic: license-file
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sdev"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.4"
|
|
8
8
|
description = "串口控制器工具包"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -30,6 +30,7 @@ requires-python = ">=3.7"
|
|
|
30
30
|
dependencies = [
|
|
31
31
|
"pyserial>=3.5",
|
|
32
32
|
"loguru>=0.6.0",
|
|
33
|
+
"zeroconf>=0.39.0",
|
|
33
34
|
]
|
|
34
35
|
|
|
35
36
|
[project.scripts]
|
|
@@ -148,9 +148,10 @@ def _get_default_baudrate() -> int:
|
|
|
148
148
|
|
|
149
149
|
def _run_with_spinner(label: str, fn, *args, **kwargs):
|
|
150
150
|
"""
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
151
|
+
在控制台上为一个阻塞操作显示简易旋转动画:
|
|
152
|
+
- 进行中:⏳ + label + 旋转字符(单行反复覆盖);
|
|
153
|
+
- 完成时:输出一行 ✔ + label。
|
|
154
|
+
fn: 实际执行的函数,返回结果会被原样返回。
|
|
154
155
|
"""
|
|
155
156
|
|
|
156
157
|
done = False
|
|
@@ -159,12 +160,9 @@ def _run_with_spinner(label: str, fn, *args, **kwargs):
|
|
|
159
160
|
frames = itertools.cycle("|/-\\")
|
|
160
161
|
while not done:
|
|
161
162
|
frame = next(frames)
|
|
162
|
-
sys.stdout.write(f"\r{_CYAN}{label}{_RESET} {frame}")
|
|
163
|
+
sys.stdout.write(f"\r{_CYAN}⏳ {label}{_RESET} {frame}")
|
|
163
164
|
sys.stdout.flush()
|
|
164
165
|
time.sleep(0.1)
|
|
165
|
-
# 擦掉这一行
|
|
166
|
-
sys.stdout.write("\r" + " " * (len(label) + 4) + "\r")
|
|
167
|
-
sys.stdout.flush()
|
|
168
166
|
|
|
169
167
|
t = threading.Thread(target=spinner_worker, daemon=True)
|
|
170
168
|
t.start()
|
|
@@ -173,6 +171,9 @@ def _run_with_spinner(label: str, fn, *args, **kwargs):
|
|
|
173
171
|
finally:
|
|
174
172
|
done = True # type: ignore[assignment]
|
|
175
173
|
t.join()
|
|
174
|
+
# 以对勾形式输出完成行
|
|
175
|
+
sys.stdout.write(f"\r{_GREEN}✔ {label}{_RESET}\n")
|
|
176
|
+
sys.stdout.flush()
|
|
176
177
|
return result
|
|
177
178
|
|
|
178
179
|
|
|
@@ -246,11 +247,11 @@ def _scan_targets_with_spinner(targets: list[dict[str, Any]], timeout: float) ->
|
|
|
246
247
|
kinds = {t.get("kind") for t in targets}
|
|
247
248
|
n = len(targets)
|
|
248
249
|
if kinds == {"local"}:
|
|
249
|
-
label = f"[
|
|
250
|
+
label = f"[LOCAL] scanning {n} port(s)"
|
|
250
251
|
elif kinds == {"remote"}:
|
|
251
|
-
label = f"[
|
|
252
|
+
label = f"[REMOTE] checking {n} board(s)"
|
|
252
253
|
else:
|
|
253
|
-
label = f"[
|
|
254
|
+
label = f"[SCAN] checking {n} target(s)"
|
|
254
255
|
|
|
255
256
|
return _run_with_spinner(label, _scan_targets, targets, timeout)
|
|
256
257
|
|
|
@@ -304,11 +305,32 @@ def cmd_server_status(args: argparse.Namespace) -> int:
|
|
|
304
305
|
def _resolve_demoboard_target_for_shell() -> Tuple[Optional[str], str, int]:
|
|
305
306
|
"""
|
|
306
307
|
解析 shell 使用的 demoboard:
|
|
307
|
-
-
|
|
308
|
+
- 优先使用「远程激活」记录(host:/dev/ttyUSBx);
|
|
309
|
+
- 其次使用「本地激活」记录(/dev/ttyUSBx);
|
|
308
310
|
- 否则回退到默认本地端口 + 波特率。
|
|
309
311
|
返回 (host, port, baudrate),其中 host 为 None 表示本地。
|
|
310
312
|
"""
|
|
311
|
-
# 1.
|
|
313
|
+
# 1. 远程激活优先:从 discovery 的 activation 记录中选择 active 的一条。
|
|
314
|
+
try:
|
|
315
|
+
remote_records = discovery.load_activations()
|
|
316
|
+
except Exception:
|
|
317
|
+
remote_records = []
|
|
318
|
+
for r in remote_records:
|
|
319
|
+
if not isinstance(r, dict):
|
|
320
|
+
continue
|
|
321
|
+
if not r.get("active"):
|
|
322
|
+
continue
|
|
323
|
+
host = (r.get("host") or "").strip()
|
|
324
|
+
port = (r.get("port") or "").strip()
|
|
325
|
+
if not host or not port:
|
|
326
|
+
continue
|
|
327
|
+
try:
|
|
328
|
+
baud = int(r.get("baudrate") or _get_default_baudrate())
|
|
329
|
+
except (TypeError, ValueError):
|
|
330
|
+
baud = _get_default_baudrate()
|
|
331
|
+
return host, port, baud
|
|
332
|
+
|
|
333
|
+
# 2. 本地激活
|
|
312
334
|
act = _load_local_activation()
|
|
313
335
|
if act and act.get("active") and act.get("port"):
|
|
314
336
|
port = str(act.get("port"))
|
|
@@ -318,7 +340,7 @@ def _resolve_demoboard_target_for_shell() -> Tuple[Optional[str], str, int]:
|
|
|
318
340
|
baud = _get_default_baudrate()
|
|
319
341
|
return None, port, baud
|
|
320
342
|
|
|
321
|
-
# 默认本地
|
|
343
|
+
# 3. 默认本地
|
|
322
344
|
return None, _get_default_port(), _get_default_baudrate()
|
|
323
345
|
|
|
324
346
|
|
|
@@ -332,14 +354,12 @@ def cmd_shell(args: argparse.Namespace) -> int:
|
|
|
332
354
|
|
|
333
355
|
with Demoboard(port, baudrate, host=host) as board:
|
|
334
356
|
try:
|
|
335
|
-
|
|
357
|
+
_ = board.execute_command(args.command, flag=args.flag, timeout=args.timeout)
|
|
336
358
|
except TimeoutError as e:
|
|
337
359
|
print(e, file=sys.stderr)
|
|
338
360
|
return 1
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
sys.stdout.write(line)
|
|
342
|
-
sys.stdout.flush()
|
|
361
|
+
print()
|
|
362
|
+
# sys.stdout.flush()
|
|
343
363
|
return 0
|
|
344
364
|
|
|
345
365
|
|
|
@@ -472,6 +492,9 @@ def cmd_demoboard_list(args: argparse.Namespace) -> int:
|
|
|
472
492
|
if args.local:
|
|
473
493
|
return cmd_list_local(args)
|
|
474
494
|
|
|
495
|
+
# 延迟导入以避免无关子命令时的额外依赖开销。
|
|
496
|
+
from ..base.serial_core import scan_serial_ports
|
|
497
|
+
|
|
475
498
|
# 本地与远程发现阶段并行:
|
|
476
499
|
# - 线程 A:本地串口非阻塞检查(不输出);
|
|
477
500
|
# - 线程 B:远程 host 发现 + boards 列表拉取(不输出);
|
|
@@ -504,17 +527,47 @@ def cmd_demoboard_list(args: argparse.Namespace) -> int:
|
|
|
504
527
|
def _local_worker() -> None:
|
|
505
528
|
nonlocal local_results
|
|
506
529
|
try:
|
|
530
|
+
if local_targets:
|
|
531
|
+
print(f"{_CYAN}[LOCAL]{_RESET} scanning {len(local_targets)} local port(s)...")
|
|
532
|
+
else:
|
|
533
|
+
print(f"{_YELLOW}[LOCAL]{_RESET} 未发现任何本机串口(ttyUSB*)。")
|
|
507
534
|
local_results = _scan_targets(local_targets, args.timeout)
|
|
535
|
+
if local_results:
|
|
536
|
+
up_count = sum(1 for r in local_results if r.get("available"))
|
|
537
|
+
print(f"{_CYAN}[LOCAL]{_RESET} scan done: {up_count}/{len(local_results)} up")
|
|
508
538
|
finally:
|
|
509
539
|
local_done.set()
|
|
510
540
|
|
|
511
541
|
def _remote_discovery_worker() -> None:
|
|
512
542
|
nonlocal all_boards
|
|
513
543
|
try:
|
|
514
|
-
|
|
544
|
+
print(f"{_CYAN}[REMOTE]{_RESET} discovering remote hosts...")
|
|
545
|
+
hosts = discovery.get_remote_hosts(
|
|
546
|
+
service_port=service_port,
|
|
547
|
+
override=getattr(args, "remote_hosts", None),
|
|
548
|
+
)
|
|
549
|
+
if not hosts:
|
|
550
|
+
print(
|
|
551
|
+
f"{_YELLOW}[REMOTE]{_RESET} 未发现任何在线 host(请确认各机已执行 'sdev server start' 且在同一网络)"
|
|
552
|
+
)
|
|
515
553
|
for host, port in hosts:
|
|
516
554
|
boards = discovery.fetch_boards_from_host(host, port, timeout=args.timeout)
|
|
517
555
|
all_boards.append(boards)
|
|
556
|
+
up_count = sum(1 for b in boards if b.get("available"))
|
|
557
|
+
total = len(boards)
|
|
558
|
+
if total == 0:
|
|
559
|
+
state = "EMPTY"
|
|
560
|
+
extra = "no boards reported"
|
|
561
|
+
color = _YELLOW
|
|
562
|
+
elif up_count > 0:
|
|
563
|
+
state = "UP"
|
|
564
|
+
extra = f"{up_count}/{total} up"
|
|
565
|
+
color = _GREEN
|
|
566
|
+
else:
|
|
567
|
+
state = "DOWN"
|
|
568
|
+
extra = f"0/{total} up"
|
|
569
|
+
color = _RED
|
|
570
|
+
print(f"{color}[remote {state}]{_RESET} {host}:{port} - {extra}")
|
|
518
571
|
finally:
|
|
519
572
|
remote_done.set()
|
|
520
573
|
|
|
@@ -528,7 +581,9 @@ def cmd_demoboard_list(args: argparse.Namespace) -> int:
|
|
|
528
581
|
local_done.wait()
|
|
529
582
|
remote_done.wait()
|
|
530
583
|
|
|
531
|
-
|
|
584
|
+
# 这里不再使用单行 spinner,以免与本地/远程的进度日志挤在同一行。
|
|
585
|
+
print(f"{_CYAN}[INFO]{_RESET} scanning local & discovering remote ...")
|
|
586
|
+
_wait_both()
|
|
532
587
|
|
|
533
588
|
t_local.join()
|
|
534
589
|
t_remote.join()
|
|
@@ -644,7 +699,6 @@ def cmd_remote_activate(args: argparse.Namespace) -> int:
|
|
|
644
699
|
reason = result.get("reason", "")
|
|
645
700
|
status = "OK" if ok else "FAILED"
|
|
646
701
|
color = _GREEN if ok else _RED
|
|
647
|
-
prefix = _format_shell_prefix(host, serial_port, baudrate)
|
|
648
702
|
msg = f"probe {host}:{serial_port} baud={baudrate} -> {status}"
|
|
649
703
|
if reason and not ok:
|
|
650
704
|
msg += f" ({reason})"
|
|
@@ -682,8 +736,6 @@ def cmd_remote_activate(args: argparse.Namespace) -> int:
|
|
|
682
736
|
}
|
|
683
737
|
)
|
|
684
738
|
discovery.save_activations(records)
|
|
685
|
-
if ok:
|
|
686
|
-
print(f"{_CYAN}{prefix}{_RESET}")
|
|
687
739
|
return 0 if ok else 1
|
|
688
740
|
|
|
689
741
|
|
|
@@ -703,7 +755,6 @@ def cmd_activate_local(args: argparse.Namespace) -> int:
|
|
|
703
755
|
reason = result.get("reason", "")
|
|
704
756
|
status = "OK" if ok else "FAILED"
|
|
705
757
|
color = _GREEN if ok else _RED
|
|
706
|
-
prefix = _format_shell_prefix(None, port, baudrate)
|
|
707
758
|
msg = f"probe local {port} baud={baudrate} -> {status}"
|
|
708
759
|
if reason and not ok:
|
|
709
760
|
msg += f" ({reason})"
|
|
@@ -716,8 +767,6 @@ def cmd_activate_local(args: argparse.Namespace) -> int:
|
|
|
716
767
|
"last_reason": reason,
|
|
717
768
|
}
|
|
718
769
|
_save_local_activation(record)
|
|
719
|
-
if ok:
|
|
720
|
-
print(f"{_CYAN}{prefix}{_RESET}")
|
|
721
770
|
return 0 if ok else 1
|
|
722
771
|
|
|
723
772
|
|
|
@@ -793,13 +842,16 @@ def cmd_server_start(args: argparse.Namespace) -> int:
|
|
|
793
842
|
python = sys.executable or "python3"
|
|
794
843
|
cmd = [python, "-m", "sdev.remote.server", str(port)]
|
|
795
844
|
|
|
796
|
-
# 将输出重定向到日志文件
|
|
797
|
-
log_path = _get_server_log_path()
|
|
798
|
-
log_file = open(log_path, "a", encoding="utf-8")
|
|
799
|
-
|
|
800
845
|
# cwd 设为项目根目录(包含顶层 sdev 包),以确保使用当前源码而不是已安装旧版本。
|
|
846
|
+
# server 进程内部会自行将日志写入 server.log(带大小限制),
|
|
847
|
+
# 这里无需再重定向 stdout/stderr 到日志文件。
|
|
801
848
|
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
802
|
-
proc = subprocess.Popen(
|
|
849
|
+
proc = subprocess.Popen(
|
|
850
|
+
cmd,
|
|
851
|
+
stdout=subprocess.DEVNULL,
|
|
852
|
+
stderr=subprocess.DEVNULL,
|
|
853
|
+
cwd=project_root,
|
|
854
|
+
)
|
|
803
855
|
with open(_get_server_pid_path(), "w", encoding="utf-8") as f:
|
|
804
856
|
f.write(str(proc.pid))
|
|
805
857
|
print(f"sdev server 已启动:port={port}, pid={proc.pid}")
|
|
@@ -19,6 +19,73 @@ from typing import Any
|
|
|
19
19
|
DISCOVERY_PORT = 7001
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
def _debug(msg: str) -> None:
|
|
23
|
+
"""
|
|
24
|
+
轻量级调试输出:
|
|
25
|
+
- 仅在环境变量 SDEV_REMOTE_DEBUG 为非空时生效;
|
|
26
|
+
- 直接写入 stderr,避免额外依赖。
|
|
27
|
+
"""
|
|
28
|
+
if not os.environ.get("SDEV_REMOTE_DEBUG"):
|
|
29
|
+
return
|
|
30
|
+
try:
|
|
31
|
+
sys.stderr.write(f"[sdev-remote-debug] {msg}\n")
|
|
32
|
+
sys.stderr.flush()
|
|
33
|
+
except OSError:
|
|
34
|
+
# 调试输出失败时静默忽略,避免影响正常流程
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _discover_hosts_via_zeroconf(timeout: float = 0.8) -> list[tuple[str, int]]:
|
|
39
|
+
"""
|
|
40
|
+
通过 zeroconf(mDNS) 发现在线 host:
|
|
41
|
+
- server 侧会发布 `_sdev._tcp.local.` 服务;
|
|
42
|
+
- 这里简单浏览该服务若干秒,收集所有实例的 IP + 端口。
|
|
43
|
+
"""
|
|
44
|
+
# 允许通过环境变量彻底关闭 zeroconf,便于在特殊网络或调试时缩短等待。
|
|
45
|
+
if os.environ.get("SDEV_DISABLE_ZEROCONF"):
|
|
46
|
+
_debug("zeroconf discovery disabled by SDEV_DISABLE_ZEROCONF")
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf
|
|
51
|
+
except Exception as e: # pragma: no cover - 依赖缺失时直接跳过
|
|
52
|
+
_debug(f"zeroconf unavailable, skip mDNS discovery: {e}")
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
found: dict[tuple[str, int], None] = {}
|
|
56
|
+
|
|
57
|
+
def _on_service_state(zeroconf, service_type, name, state_change) -> None:
|
|
58
|
+
if state_change is not ServiceStateChange.Added:
|
|
59
|
+
return
|
|
60
|
+
try:
|
|
61
|
+
info = zeroconf.get_service_info(service_type, name, timeout=1000)
|
|
62
|
+
except Exception:
|
|
63
|
+
return
|
|
64
|
+
if not info:
|
|
65
|
+
return
|
|
66
|
+
addrs = getattr(info, "parsed_addresses", lambda: [])()
|
|
67
|
+
if not addrs:
|
|
68
|
+
return
|
|
69
|
+
host = (addrs[0] or "").strip()
|
|
70
|
+
port = int(getattr(info, "port", 0) or 0)
|
|
71
|
+
if host and port:
|
|
72
|
+
found[(host, port)] = None
|
|
73
|
+
|
|
74
|
+
zc = Zeroconf()
|
|
75
|
+
try:
|
|
76
|
+
ServiceBrowser(zc, "_sdev._tcp.local.", handlers=[_on_service_state])
|
|
77
|
+
time.sleep(max(0.1, timeout))
|
|
78
|
+
finally:
|
|
79
|
+
try:
|
|
80
|
+
zc.close()
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
hosts = [(h, p) for (h, p) in found.keys()]
|
|
85
|
+
_debug(f"zeroconf discovery hosts={hosts}")
|
|
86
|
+
return hosts
|
|
87
|
+
|
|
88
|
+
|
|
22
89
|
def get_discovery_cache_path() -> str:
|
|
23
90
|
if sys.platform == "win32":
|
|
24
91
|
base = os.environ.get("APPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Roaming")
|
|
@@ -70,6 +137,7 @@ def _discover_hosts_via_udp(timeout: float = 1.5) -> list[tuple[str, int]]:
|
|
|
70
137
|
targets.add(("255.255.255.255", DISCOVERY_PORT))
|
|
71
138
|
|
|
72
139
|
# 发送探测报文到所有候选地址(通常只有 1~数个,很轻量)
|
|
140
|
+
_debug(f"UDP discovery targets={list(targets)}, timeout={timeout}")
|
|
73
141
|
for host, port in targets:
|
|
74
142
|
try:
|
|
75
143
|
sock.sendto(b"SDEV_DISCOVERY", (host, port))
|
|
@@ -95,6 +163,13 @@ def _discover_hosts_via_udp(timeout: float = 1.5) -> list[tuple[str, int]]:
|
|
|
95
163
|
obj = json.loads(data.decode("utf-8"))
|
|
96
164
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
97
165
|
continue
|
|
166
|
+
|
|
167
|
+
# 仅接受带有正确 magic 的 sdev 响应,避免误把其他服务当成 sdev server。
|
|
168
|
+
magic = (obj.get("magic") or "").strip()
|
|
169
|
+
if magic != "sdev-remote-v1":
|
|
170
|
+
_debug(f"UDP discovery ignore packet from {addr}: magic={magic!r}")
|
|
171
|
+
continue
|
|
172
|
+
|
|
98
173
|
host = (obj.get("host") or addr[0]).strip()
|
|
99
174
|
try:
|
|
100
175
|
port = int(obj.get("port") or 7000)
|
|
@@ -102,7 +177,9 @@ def _discover_hosts_via_udp(timeout: float = 1.5) -> list[tuple[str, int]]:
|
|
|
102
177
|
port = 7000
|
|
103
178
|
if host:
|
|
104
179
|
hosts.add((host, port))
|
|
105
|
-
|
|
180
|
+
out = list(hosts)
|
|
181
|
+
_debug(f"UDP discovery got hosts={out}")
|
|
182
|
+
return out
|
|
106
183
|
finally:
|
|
107
184
|
try:
|
|
108
185
|
sock.close()
|
|
@@ -131,9 +208,35 @@ def get_remote_hosts(service_port: int = 7000, override: str | None = None) -> l
|
|
|
131
208
|
out.append((s, service_port))
|
|
132
209
|
else:
|
|
133
210
|
out.append((s, service_port))
|
|
211
|
+
_debug(f"get_remote_hosts from override/raw='{raw}' -> {out}")
|
|
134
212
|
return out
|
|
135
213
|
|
|
136
|
-
|
|
214
|
+
# 组合 UDP + zeroconf 两种发现方式,去重后返回:
|
|
215
|
+
# - UDP:对简单局域网兼容性最好;
|
|
216
|
+
# - zeroconf:对 Mac 等环境在多播受限时更友好。
|
|
217
|
+
udp_hosts = _discover_hosts_via_udp()
|
|
218
|
+
zc_hosts = _discover_hosts_via_zeroconf()
|
|
219
|
+
merged: dict[tuple[str, int], None] = {}
|
|
220
|
+
for h, p in udp_hosts + zc_hosts:
|
|
221
|
+
# service_port 仅在 host 省略端口时起作用,这里已是 (host, port) 形式,直接使用。
|
|
222
|
+
merged[(h, int(p or service_port))] = None
|
|
223
|
+
|
|
224
|
+
# 过滤掉本机 IP(包括 127.0.0.1),避免把自己当作 remote host。
|
|
225
|
+
import socket
|
|
226
|
+
|
|
227
|
+
local_ips: set[str] = {"127.0.0.1"}
|
|
228
|
+
try:
|
|
229
|
+
hostname = socket.gethostname()
|
|
230
|
+
for info in socket.getaddrinfo(hostname, None, family=socket.AF_INET):
|
|
231
|
+
ip = info[4][0]
|
|
232
|
+
if ip:
|
|
233
|
+
local_ips.add(ip)
|
|
234
|
+
except OSError:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
hosts = [(h, p) for (h, p) in merged.keys() if h not in local_ips]
|
|
238
|
+
_debug(f"get_remote_hosts via UDP+zeroconf -> {hosts} (local_ips={list(local_ips)})")
|
|
239
|
+
return hosts
|
|
137
240
|
|
|
138
241
|
|
|
139
242
|
def load_discovery_cache() -> list[dict[str, Any]]:
|
|
@@ -159,6 +262,8 @@ def fetch_boards_from_host(host: str, port: int, timeout: float = 10.0) -> list[
|
|
|
159
262
|
"""向 host:port 发送 list 请求,返回带 host 字段的 boards 列表。"""
|
|
160
263
|
import socket
|
|
161
264
|
|
|
265
|
+
_debug(f"fetch_boards_from_host: connect to {host}:{port}, timeout={timeout}")
|
|
266
|
+
|
|
162
267
|
try:
|
|
163
268
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
164
269
|
sock.settimeout(timeout)
|
|
@@ -171,12 +276,14 @@ def fetch_boards_from_host(host: str, port: int, timeout: float = 10.0) -> list[
|
|
|
171
276
|
break
|
|
172
277
|
buf += chunk
|
|
173
278
|
sock.close()
|
|
174
|
-
except Exception:
|
|
279
|
+
except Exception as e:
|
|
280
|
+
_debug(f"fetch_boards_from_host: network error host={host} port={port} err={e!r}")
|
|
175
281
|
return []
|
|
176
282
|
try:
|
|
177
283
|
line = buf.split(b"\n", 1)[0].decode("utf-8")
|
|
178
284
|
obj = json.loads(line)
|
|
179
285
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
286
|
+
_debug(f"fetch_boards_from_host: invalid response host={host} port={port} raw={buf!r}")
|
|
180
287
|
return []
|
|
181
288
|
boards = obj.get("boards") if isinstance(obj, dict) else []
|
|
182
289
|
if not isinstance(boards, list):
|
|
@@ -184,6 +291,10 @@ def fetch_boards_from_host(host: str, port: int, timeout: float = 10.0) -> list[
|
|
|
184
291
|
for b in boards:
|
|
185
292
|
if isinstance(b, dict):
|
|
186
293
|
b["host"] = host
|
|
294
|
+
_debug(
|
|
295
|
+
f"fetch_boards_from_host: parsed boards from {host}:{port} -> "
|
|
296
|
+
f"{[{'port': b.get('port'), 'baudrate': b.get('baudrate'), 'available': b.get('available')} for b in boards]}"
|
|
297
|
+
)
|
|
187
298
|
return [b for b in boards if isinstance(b, dict)]
|
|
188
299
|
|
|
189
300
|
|
|
@@ -247,6 +358,10 @@ def probe_remote_port(
|
|
|
247
358
|
"port": serial_port,
|
|
248
359
|
"baudrate": baudrate,
|
|
249
360
|
}
|
|
361
|
+
_debug(
|
|
362
|
+
f"probe_remote_port: connect to {host}:{service_port}, "
|
|
363
|
+
f"serial_port={serial_port}, baudrate={baudrate}, timeout={timeout}"
|
|
364
|
+
)
|
|
250
365
|
try:
|
|
251
366
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
252
367
|
sock.settimeout(timeout)
|
|
@@ -261,6 +376,10 @@ def probe_remote_port(
|
|
|
261
376
|
buf += chunk
|
|
262
377
|
sock.close()
|
|
263
378
|
except Exception as e:
|
|
379
|
+
_debug(
|
|
380
|
+
f"probe_remote_port: network error host={host} service_port={service_port} "
|
|
381
|
+
f"serial_port={serial_port} err={e!r}"
|
|
382
|
+
)
|
|
264
383
|
return {
|
|
265
384
|
"port": serial_port,
|
|
266
385
|
"baudrate": baudrate,
|
|
@@ -272,6 +391,10 @@ def probe_remote_port(
|
|
|
272
391
|
line = buf.split(b"\n", 1)[0].decode("utf-8")
|
|
273
392
|
obj = json.loads(line)
|
|
274
393
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
394
|
+
_debug(
|
|
395
|
+
f"probe_remote_port: invalid response host={host} service_port={service_port} "
|
|
396
|
+
f"serial_port={serial_port} raw={buf!r}"
|
|
397
|
+
)
|
|
275
398
|
return {
|
|
276
399
|
"port": serial_port,
|
|
277
400
|
"baudrate": baudrate,
|
|
@@ -280,6 +403,10 @@ def probe_remote_port(
|
|
|
280
403
|
}
|
|
281
404
|
|
|
282
405
|
if not isinstance(obj, dict):
|
|
406
|
+
_debug(
|
|
407
|
+
f"probe_remote_port: response is not dict host={host} service_port={service_port} "
|
|
408
|
+
f"serial_port={serial_port} obj_type={type(obj)}"
|
|
409
|
+
)
|
|
283
410
|
return {
|
|
284
411
|
"port": serial_port,
|
|
285
412
|
"baudrate": baudrate,
|
|
@@ -289,6 +416,10 @@ def probe_remote_port(
|
|
|
289
416
|
# 远端直接返回 result 字典
|
|
290
417
|
result = obj.get("result") if "result" in obj else obj
|
|
291
418
|
if not isinstance(result, dict):
|
|
419
|
+
_debug(
|
|
420
|
+
f"probe_remote_port: result is not dict host={host} service_port={service_port} "
|
|
421
|
+
f"serial_port={serial_port} result_type={type(result)}"
|
|
422
|
+
)
|
|
292
423
|
return {
|
|
293
424
|
"port": serial_port,
|
|
294
425
|
"baudrate": baudrate,
|
|
@@ -300,6 +431,10 @@ def probe_remote_port(
|
|
|
300
431
|
result.setdefault("baudrate", baudrate)
|
|
301
432
|
result.setdefault("available", False)
|
|
302
433
|
result.setdefault("reason", "")
|
|
434
|
+
_debug(
|
|
435
|
+
f"probe_remote_port: ok host={host} service_port={service_port} serial_port={serial_port} "
|
|
436
|
+
f"available={result.get('available')} reason={result.get('reason')!r}"
|
|
437
|
+
)
|
|
303
438
|
return result
|
|
304
439
|
|
|
305
440
|
|
|
@@ -88,8 +88,12 @@ def _discovery_loop(udp_sock, service_port: int) -> None:
|
|
|
88
88
|
if msg != b"SDEV_DISCOVERY":
|
|
89
89
|
continue
|
|
90
90
|
logger.info(f"[server] discovery ping from {addr}, respond with port={service_port}")
|
|
91
|
+
# 回传固定 magic + TCP 服务端口,便于客户端甄别非 sdev 的 UDP 应答。
|
|
91
92
|
resp = json.dumps(
|
|
92
|
-
{
|
|
93
|
+
{
|
|
94
|
+
"magic": "sdev-remote-v1",
|
|
95
|
+
"port": service_port,
|
|
96
|
+
},
|
|
93
97
|
ensure_ascii=False,
|
|
94
98
|
).encode("utf-8")
|
|
95
99
|
try:
|
|
@@ -98,6 +102,44 @@ def _discovery_loop(udp_sock, service_port: int) -> None:
|
|
|
98
102
|
continue
|
|
99
103
|
|
|
100
104
|
|
|
105
|
+
def _get_server_log_path() -> str:
|
|
106
|
+
"""
|
|
107
|
+
server.log 路径计算逻辑,与 CLI 侧保持一致:
|
|
108
|
+
默认 ~/.cache/sdev/server.log 或 XDG_RUNTIME_DIR/sdev/server.log。
|
|
109
|
+
"""
|
|
110
|
+
import os
|
|
111
|
+
|
|
112
|
+
base = os.environ.get("XDG_RUNTIME_DIR") or os.path.join(os.path.expanduser("~"), ".cache")
|
|
113
|
+
log_dir = os.path.join(base, "sdev")
|
|
114
|
+
os.makedirs(log_dir, mode=0o700, exist_ok=True)
|
|
115
|
+
return os.path.join(log_dir, "server.log")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _setup_logging() -> None:
|
|
119
|
+
"""
|
|
120
|
+
配置 loguru 日志输出到 server.log,并限制最大大小:
|
|
121
|
+
- 单个日志文件按 1 MiB 自动轮转;
|
|
122
|
+
- 保留若干历史文件,避免无限增长。
|
|
123
|
+
"""
|
|
124
|
+
import os
|
|
125
|
+
|
|
126
|
+
log_path = _get_server_log_path()
|
|
127
|
+
# 确保目录存在(_get_server_log_path 已处理),这里冗余一次也无妨。
|
|
128
|
+
os.makedirs(os.path.dirname(log_path) or ".", exist_ok=True)
|
|
129
|
+
|
|
130
|
+
# 移除默认 stderr sink,避免重复输出。
|
|
131
|
+
logger.remove()
|
|
132
|
+
logger.add(
|
|
133
|
+
log_path,
|
|
134
|
+
rotation="1 MB", # 单文件约 1 MiB 时自动轮转
|
|
135
|
+
retention=5, # 最多保留 5 个历史文件
|
|
136
|
+
encoding="utf-8",
|
|
137
|
+
enqueue=True,
|
|
138
|
+
backtrace=False,
|
|
139
|
+
diagnose=False,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
101
143
|
def _handle(conn, addr):
|
|
102
144
|
import json
|
|
103
145
|
|
|
@@ -199,6 +241,48 @@ class RemoteServer:
|
|
|
199
241
|
|
|
200
242
|
def __init__(self, port: int = 7000):
|
|
201
243
|
self.port = port
|
|
244
|
+
# 保留 zeroconf 对象引用,避免被 GC 提前释放
|
|
245
|
+
self._zeroconf = None
|
|
246
|
+
self._zeroconf_info = None
|
|
247
|
+
|
|
248
|
+
def _start_zeroconf(self) -> None:
|
|
249
|
+
"""
|
|
250
|
+
通过 zeroconf(mDNS) 发布 sdev server 服务,便于在不支持 UDP 广播的环境中发现。
|
|
251
|
+
失败时静默降级,不影响主流程。
|
|
252
|
+
"""
|
|
253
|
+
try:
|
|
254
|
+
import socket
|
|
255
|
+
from zeroconf import IPVersion, ServiceInfo, Zeroconf
|
|
256
|
+
except Exception as e: # pragma: no cover - 依赖缺失时直接跳过
|
|
257
|
+
logger.warning(f"[server] zeroconf unavailable, skip mDNS advertise: {e}")
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
host_ip = socket.gethostbyname(socket.gethostname())
|
|
262
|
+
except OSError:
|
|
263
|
+
# 回退到本地回环地址,至少不会报错;client 侧会同时使用 UDP 发现。
|
|
264
|
+
host_ip = "127.0.0.1"
|
|
265
|
+
|
|
266
|
+
service_type = "_sdev._tcp.local."
|
|
267
|
+
service_name = f"sdev-{host_ip}-{self.port}.{service_type}"
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
info = ServiceInfo(
|
|
271
|
+
type_=service_type,
|
|
272
|
+
name=service_name,
|
|
273
|
+
addresses=[socket.inet_aton(host_ip)],
|
|
274
|
+
port=self.port,
|
|
275
|
+
properties={},
|
|
276
|
+
)
|
|
277
|
+
zc = Zeroconf(ip_version=IPVersion.V4Only)
|
|
278
|
+
zc.register_service(info)
|
|
279
|
+
except Exception as e: # pragma: no cover - mDNS 故障时直接降级
|
|
280
|
+
logger.warning(f"[server] zeroconf advertise failed: {e}")
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
self._zeroconf = zc
|
|
284
|
+
self._zeroconf_info = info
|
|
285
|
+
logger.info(f"[server] zeroconf advertised as {service_name} ({host_ip}:{self.port})")
|
|
202
286
|
|
|
203
287
|
def serve_forever(self) -> int:
|
|
204
288
|
import socket
|
|
@@ -214,6 +298,9 @@ class RemoteServer:
|
|
|
214
298
|
udp_sock.bind(("0.0.0.0", 7001))
|
|
215
299
|
threading.Thread(target=_discovery_loop, args=(udp_sock, self.port), daemon=True).start()
|
|
216
300
|
|
|
301
|
+
# 通过 zeroconf 发布服务,作为 UDP discovery 的补充通道。
|
|
302
|
+
self._start_zeroconf()
|
|
303
|
+
|
|
217
304
|
logger.info(f"[server] listening on 0.0.0.0:{self.port} (TCP) and 0.0.0.0:7001 (UDP discovery)")
|
|
218
305
|
while True:
|
|
219
306
|
conn, addr = sock.accept()
|
|
@@ -227,12 +314,15 @@ def main() -> int:
|
|
|
227
314
|
"""
|
|
228
315
|
import sys
|
|
229
316
|
|
|
317
|
+
# 在解析参数后立即配置日志,保证后续日志都写入带大小限制的 server.log。
|
|
230
318
|
port: int = 7000
|
|
231
319
|
if len(sys.argv) > 1:
|
|
232
320
|
try:
|
|
233
321
|
port = int(sys.argv[1])
|
|
234
322
|
except ValueError:
|
|
235
323
|
pass
|
|
324
|
+
|
|
325
|
+
_setup_logging()
|
|
236
326
|
server = RemoteServer(port=port)
|
|
237
327
|
server.serve_forever()
|
|
238
328
|
return 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sdev
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.4
|
|
4
4
|
Summary: 串口控制器工具包
|
|
5
5
|
Home-page: https://github.com/klrc/sdev
|
|
6
6
|
Author: klrc
|
|
@@ -27,6 +27,7 @@ Description-Content-Type: text/markdown
|
|
|
27
27
|
License-File: LICENSE
|
|
28
28
|
Requires-Dist: pyserial>=3.5
|
|
29
29
|
Requires-Dist: loguru>=0.6.0
|
|
30
|
+
Requires-Dist: zeroconf>=0.39.0
|
|
30
31
|
Dynamic: author
|
|
31
32
|
Dynamic: home-page
|
|
32
33
|
Dynamic: license-file
|
|
@@ -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.5.
|
|
8
|
+
version="0.5.4",
|
|
9
9
|
author="klrc",
|
|
10
10
|
author_email="144069824@qq.com",
|
|
11
11
|
description="串口控制器工具包",
|
|
@@ -31,6 +31,7 @@ setup(
|
|
|
31
31
|
install_requires=[
|
|
32
32
|
"pyserial>=3.5",
|
|
33
33
|
"loguru>=0.6.0",
|
|
34
|
+
"zeroconf>=0.39.0",
|
|
34
35
|
],
|
|
35
36
|
entry_points={
|
|
36
37
|
"console_scripts": ["sdev=sdev.cli.cli_wrapper: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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|