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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sdev
3
- Version: 0.5.2
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.2"
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]
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  from .models import Demoboard
11
11
 
12
- __version__ = "0.5.2"
12
+ __version__ = "0.5.4"
13
13
  __author__ = "klrc"
14
14
  __email__ = "1440698245@qq.com"
15
15
 
@@ -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
- label: 文本前缀,如 "[remote] probing 192.168.1.10:7000"
153
- fn: 实际执行的函数,返回结果会被原样返回。
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"[local] scanning {n} port(s)"
250
+ label = f"[LOCAL] scanning {n} port(s)"
250
251
  elif kinds == {"remote"}:
251
- label = f"[remote] checking {n} board(s)"
252
+ label = f"[REMOTE] checking {n} board(s)"
252
253
  else:
253
- label = f"[scan] checking {n} target(s)"
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
- lines = board.execute_command(args.command, flag=args.flag, timeout=args.timeout)
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
- for line in lines:
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
- hosts = discovery.get_remote_hosts(service_port=service_port, override=getattr(args, "remote_hosts", None))
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
- _run_with_spinner("[INFO] scanning local & discovering remote", _wait_both)
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(cmd, stdout=log_file, stderr=subprocess.STDOUT, cwd=project_root)
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
- return list(hosts)
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
- return _discover_hosts_via_udp()
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
- {"host": addr[0], "port": service_port},
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.2
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
@@ -1,2 +1,3 @@
1
1
  pyserial>=3.5
2
2
  loguru>=0.6.0
3
+ zeroconf>=0.39.0
@@ -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.2",
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