feat(dsc): Add the SerDes DSC scan and its offline CSV parser

bcm_dsc_scan.sh runs

    bcmcmd -n <unit> -c "dsh -c 'phydiag <port> dsc'"

over the whole port list (one 100G port plus the 200G ranges, ~445 in
total) on both units, writes the raw log under <script_dir>/log, and
copies it to /host/hw-eval/jobs. -f names the log (%u -> u0/u1, %t ->
timestamp), -F overwrites, -u picks one unit, -n dry runs, -C skips the
copy.

Two things it deliberately does not do:

  - judge success by bcmcmd's exit code. The diag shell rejects commands
    and bcmcmd still exits 0, so the script counts
    "SERDES DISPLAY DIAG DATA END" markers instead. Same trap
    blanton_traffic_linespeed.sh hit.
  - resolve ~. Script A calls it after sudo -i, so paths are derived
    from $0 rather than $HOME.

Output is one line per port when stdout is not a tty, so it does not
flood the Tera Term log, and it ends with "### DSC_SCAN_DONE rc=<n>" for
a TTL wait to key on.

bcm_dsc_parse.sh is the host-side half: raw log -> <prefix>_lane.csv
(TXEQ / RXFFE / DFE / EYE / SNR per lane) and <prefix>_pm.csv (per
pm_id). POSIX sh + awk, no Python. Lane rows are read by the order of
their bracket groups rather than by fixed columns, so the SDK re-spacing
its output does not break parsing; a row whose group count is not 9 is
reported as UNPARSED on stderr instead of being parsed wrongly.

A full scan is about 10 minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RetWKFZFG1ZHcitQAyhwM
This commit is contained in:
2026-09-06 23:22:50 +08:00
co-authored by Claude Opus 5
parent eacb218da8
commit b4148c9571
2 changed files with 539 additions and 0 deletions
@@ -0,0 +1,204 @@
#!/bin/sh
# =============================================================================
# bcm_dsc_parse_V1.0.0.sh
#
# Author : ETWen
# Date : 20260906
#
# Version History
# V1.0.0 20260906 Initial Version
#
# Description:
# Parse Broadcom diag shell "phydiag <pm_id> dsc" raw log into CSV.
# 支援多個 pm_id 串接在同一份 log,以 "pm_id =" 當 record separator。
#
# Output:
# <prefix>_lane.csv : 每個 lane 一列(TXEQ / RXFFE / DFE / EYE / SNR ...)
# <prefix>_pm.csv : 每個 pm_id 一列(溫度 / PLL / lane 數 / SNR-EYE 統計)
#
# Usage:
# ./bcm_dsc_parse_V1.0.0.sh -i dsc_raw.log [-o out_prefix]
#
# Design notes:
# - 純 POSIX sh + awk(mawk 相容),不依賴 Python。
# - Lane row 採「括號群組出現順序」抽取,而非固定欄位位置,對排版空白
# 變動免疫。SDK 若增減欄位,群組數不等於 9,該列會被標記 UNPARSED
# 並輸出到 stderr,不會靜默解析錯誤。
# - 已知 header 名稱數與資料欄數不一致之處:
# * (P RX, CDRxN, UC_CFG, UC_STS, RST, STP) 6 個名字 / 7 個值
# (RST 實際是 "0,0" 兩欄)
# * TXEQ 尾端多一個逗號
# * AVG_TMON 顯示為 "(11) 66C" 兩個 token
# - SD / LCK 的 "*" 為 sticky 旗標,本版拆成獨立欄位保留,不做判讀。
# - BER 欄若為 "!chk_en" 表示 checker 未開啟,值無效,原樣保留。
# =============================================================================
set -e
IN=""
PREFIX="dsc"
usage() {
echo "Usage: $0 -i <raw_log> [-o <out_prefix>]" >&2
exit 1
}
while getopts "i:o:h" opt; do
case "$opt" in
i) IN="$OPTARG" ;;
o) PREFIX="$OPTARG" ;;
*) usage ;;
esac
done
[ -n "$IN" ] || usage
[ -f "$IN" ] || { echo "ERROR: input not found: $IN" >&2; exit 2; }
LANE_CSV="${PREFIX}_lane.csv"
PM_CSV="${PREFIX}_pm.csv"
# 先去掉 CR(console / telnet log 常帶),再交給 awk
tr -d '\r' < "$IN" | awk -v LANE_CSV="$LANE_CSV" -v PM_CSV="$PM_CSV" '
function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
# 掃描一列,取出所有頂層括號群組到 G[],群組前的英數 prefix 到 P[],
# 其餘純量文字留在 REST。回傳群組數。
function split_groups(line, i, ch, d, buf, ng, rest, prefix) {
split("", G, ":"); split("", P, ":")
ng = 0; d = 0; buf = ""; rest = ""; prefix = ""
for (i = 1; i <= length(line); i++) {
ch = substr(line, i, 1)
if (ch == "(") {
d++
if (d == 1) {
buf = ""; prefix = ""
if (match(rest, /[A-Za-z_]+$/)) {
prefix = substr(rest, RSTART, RLENGTH)
rest = substr(rest, 1, RSTART - 1)
}
continue
}
}
if (ch == ")") {
d--
if (d == 0) { ng++; G[ng] = buf; P[ng] = prefix; rest = rest " "; continue }
}
if (d > 0) { buf = buf ch; continue }
rest = rest ch
}
REST = rest
return ng
}
# 取括號群組的第 n 個子欄位(以逗號分隔)
function gf(idx, n, a, c) {
c = split(G[idx], a, ",")
if (n > c) return ""
return trim(a[n])
}
function is_num(s) { return (s ~ /^[+-]?[0-9]+(\.[0-9]+)?$/) }
function reset_pm() {
core = ""; sdtype = ""; hw_lanes = ""; lane_sel = ""
live_temp = ""; avg_tmon = ""; pll_lock = ""
n_lane = 0; snr_min = ""; snr_max = ""; eye_min = ""; all_lck = 1
}
function flush_pm() {
if (pm == "") return
printf "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n",
pm, core, sdtype, hw_lanes, lane_sel, n_lane,
live_temp, avg_tmon, pll_lock,
(snr_min == "" ? "NA" : snr_min),
(eye_min == "" ? "NA" : eye_min),
(n_lane == 0 ? "NA" : (all_lck ? "PASS" : "FAIL")) > PM_CSV
}
BEGIN {
pm = ""; in_lane = 0; core_row = 0; bad = 0
print "pm_id,core,lane,serdes_type,sd,sd_sticky,lck,lck_sticky,rxppm,txppm," \
"txeq_n3,txeq_n2,txeq_n1,txeq_m,txeq_p1,txeq_p2,txeq_sum," \
"rxffe_n3,rxffe_n2,rxffe_n1,rxffe_m,rxffe_p1,rxffe_p2," \
"dfe1,dfe2,pf_m,pf_l,pf_h,vga,dco,tp0,tp1,tp2,flt_m,flt_s," \
"nlc_u,nlc_l,eye_u,eye_m,eye_l,link_time,snr,ber," \
"live_temp,avg_tmon,pll_lock,rx_pol,cdr,uc_cfg,uc_sts,rst,stp" > LANE_CSV
print "pm_id,core,serdes_type,hw_lanes,lane_select,active_lanes," \
"live_temp,avg_tmon,pll_lock,snr_min,eye_min,lck_all" > PM_CSV
}
/^pm_id[ \t]*=/ { flush_pm(); pm = $NF; reset_pm(); in_lane = 0; next }
/^Core[ \t]*=/ { if (match($0, /Core[ \t]*=[ \t]*[0-9]+/)) { core = $0; sub(/.*Core[ \t]*=[ \t]*/, "", core); sub(/[^0-9].*/, "", core) } next }
/^SerDes type[ \t]*=/ { sdtype = $NF; next }
/^Rev ID # Lanes/ { hw_lanes = $NF; next }
/^lane_select[ \t]*=/ { lane_sel = $NF; next }
/^CORE[ \t]+RST_ST/ { core_row = 1; next }
core_row == 1 {
line = $0
gsub(/\([^)]*\)[ \t]*/, "", line) # 去掉 AVG_TMON 的 "(11)"
n = split(trim(line), c, /[ \t]+/)
if (n >= 15) { live_temp = c[9]; avg_tmon = c[10]; pll_lock = c[15] }
core_row = 0
next
}
/^LN[ \t]*\(/ { in_lane = 1; next }
/SERDES DISPLAY DIAG DATA END/ { in_lane = 0; next }
in_lane && /^[0-9]+[ \t]*\(/ {
ng = split_groups($0)
if (ng != 9) {
printf "UNPARSED (groups=%d) pm_id=%s: %s\n", ng, pm, $0 > "/dev/stderr"
bad++
next
}
nr = split(trim(REST), r, /[ \t]+/)
if (nr != 10) {
printf "UNPARSED (scalars=%d) pm_id=%s: %s\n", nr, pm, $0 > "/dev/stderr"
bad++
next
}
lane = r[1]
sd = r[2]; sd_st = (sd ~ /\*/) ? 1 : 0; gsub(/\*/, "", sd)
lck = r[3]; lck_st = (lck ~ /\*/) ? 1 : 0; gsub(/\*/, "", lck)
rxppm = r[4]; vga = r[5]; dco = r[6]; txppm = r[7]
link_time = r[8]; snr = r[9]; ber = r[10]
# TXEQ 六個 tap 與總和(僅在全為數值時計算)
tsum = 0; tok = 1
for (i = 1; i <= 6; i++) {
tq[i] = gf(7, i)
if (is_num(tq[i])) tsum += tq[i]; else tok = 0
}
printf "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,", pm, core, lane, sdtype, sd, sd_st, lck, lck_st, rxppm, txppm > LANE_CSV
printf "%s,%s,%s,%s,%s,%s,%s,", tq[1], tq[2], tq[3], tq[4], tq[5], tq[6], (tok ? tsum : "NA") > LANE_CSV
printf "%s,%s,%s,%s,%s,%s,", gf(4,1), gf(4,2), gf(4,3), gf(4,4), gf(4,5), gf(4,6) > LANE_CSV
printf "%s,%s,", gf(5,1), gf(5,2) > LANE_CSV
printf "%s,%s,%s,%s,%s,", gf(2,1), gf(2,2), gf(2,3), vga, dco > LANE_CSV
printf "%s,%s,%s,%s,%s,", gf(3,1), gf(3,2), gf(3,3), gf(6,1), gf(6,2) > LANE_CSV
printf "%s,%s,%s,%s,%s,", gf(8,1), gf(8,2), gf(9,1), gf(9,2), gf(9,3) > LANE_CSV
printf "%s,%s,%s,", link_time, snr, ber > LANE_CSV
printf "%s,%s,%s,", live_temp, avg_tmon, pll_lock > LANE_CSV
printf "%s,%s,%s,%s,%s/%s,%s\n", gf(1,1), gf(1,2), gf(1,3), gf(1,4), gf(1,5), gf(1,6), gf(1,7) > LANE_CSV
# per-PM 統計
n_lane++
if (lck != "1" || sd != "1") all_lck = 0
if (is_num(snr)) { if (snr_min == "" || snr + 0 < snr_min + 0) snr_min = snr }
for (i = 1; i <= 3; i++) {
e = gf(9, i)
if (is_num(e)) { if (eye_min == "" || e + 0 < eye_min + 0) eye_min = e }
}
next
}
END {
flush_pm()
if (bad > 0) printf "WARN: %d lane row(s) unparsed\n", bad > "/dev/stderr"
}
'
echo "OK: ${LANE_CSV}, ${PM_CSV}"
@@ -0,0 +1,335 @@
#!/bin/sh
# =============================================================================
# bcm_dsc_scan.sh
#
# Author : ETWen
# Date : 20260906
#
# Version History
# V1.0.0 20260906 Initial Version
# V1.1.0 20260906 Log 預設輸出至 <script_dir>/log,掃描後自動複製到
# /host/hw-eval/jobs/;非 tty 環境改用逐行進度輸出,
# 避免灌爆 Tera Term logopen 的 log
# V1.2.0 20260906 新增 -f 自訂 log 檔名(支援 %u / %t 佔位符)與
# -F 強制覆蓋;預設不覆蓋既有檔案,自動加序號
#
# Description:
# 在 Blanton SONiC DUT 上批次執行 Broadcom diag shell 的
# "phydiag <port> dsc",收集 raw log 供離線解析。
#
# 實際下的指令為:
# bcmcmd -n <unit> -c "dsh -c 'phydiag <port> dsc'"
#
# Deployment:
# Script : /root/Blanton_Script/bcm_dsc_scan.sh
# Log : /root/Blanton_Script/log/dsc_raw_u<unit>_<YYYYmmdd_HHMMSS>.log
# Copy : /host/hw-eval/jobs/
#
# Log 檔名可用 -f 覆寫,支援兩個佔位符:
# %u -> u0 / u1
# %t -> YYYYmmdd_HHMMSS
# -f 只接受檔名,不接受路徑;目錄一律由 -o 決定。
# 同時掃兩個 unit 但檔名未含 %u 時,會自動在副檔名前插入 _u<unit>,
# 避免第二個 unit 覆蓋掉第一個。
#
# 路徑由 $0 推導,不依賴 $HOME。Script A 以 sudo -i 執行時 ~ 為 /root,
# 直接寫死 ~ 會有風險,故一律使用絕對路徑。
#
# Usage:
# ./bcm_dsc_scan.sh # unit 0 與 1 都掃,掃完自動複製
# ./bcm_dsc_scan.sh -u 0 # 只掃 unit 0
# ./bcm_dsc_scan.sh -n # dry-run,只列出要掃的 port
# ./bcm_dsc_scan.sh -C # 不複製到 /host/hw-eval/jobs
# ./bcm_dsc_scan.sh -c /other/dir # 指定複製目的地
# ./bcm_dsc_scan.sh -u 0 -f dsc_before_reboot.log
# ./bcm_dsc_scan.sh -f blanton_dsc_%u_%t.log
#
# Exit code:
# 0 = 全部 port 皆取得完整 dump
# 1 = 有 port 未取得完整 dump,或複製失敗
# 2 = 參數 / 路徑錯誤
# 3 = 找不到 bcmcmd
#
# 結束時固定輸出一行 "### DSC_SCAN_DONE rc=<n>",供 TTL 端 wait 判斷。
#
# Design notes:
# - 純 POSIX sh,不依賴 bash / Python。
# - Port 清單以「範圍字串」定義在下方 PORTS_* 變數,展開後去重。
# 100G 清單先展開,故重複的 port 會被判定為 100G(目前僅 port 268)。
# - bcmcmd 即使指令失敗通常仍回傳 0,因此不以 exit code 判斷成功與否;
# 改由 marker 行 + "SERDES DISPLAY DIAG DATA END" 數量比對。
# - 先寫入本機 log 目錄再複製,而非直接寫遠端,避免中途失敗遺失整份資料。
#
# Assumptions:
# - unit 0 與 unit 1 使用相同的 port 清單。
# - phydiag 的參數為此清單中的 port 編號(非 port name)。
# - 由 root 執行(Script A 的 sudo -i 之後),故不呼叫 sudo。
#
# Cautions:
# - 全清單約 445 port x 2 unit,估計 10 分鐘,請勿把 DELAY 設為 0。
# - phydiag ... dsc 為唯讀操作,不會變更 SerDes 設定。
# =============================================================================
set -u
# ----------------------------------------------------------------------------
# Port list (以空白分隔的範圍或單一值)
# ----------------------------------------------------------------------------
PORTS_100G="268"
PORTS_200G="1-24 40-96 112-168 184-240 256-308 310-327 342-380 382-399 414-452 454-471 486-524 526-543 558-565"
# ----------------------------------------------------------------------------
# Paths / defaults
# ----------------------------------------------------------------------------
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || exit 2
UNITS="0 1"
OUTDIR="${SCRIPT_DIR}/log"
COPYDIR="/host/hw-eval/jobs"
DELAY="0.2"
DRYRUN=0
DOCOPY=1
LOGNAME="" # -f 指定的 log 檔名,空字串表示使用預設命名
FORCE=0 # -F 允許覆蓋既有檔案
PROGRESS_EVERY=50 # 非 tty 環境每 N 筆印一行進度
usage() {
cat >&2 << 'USAGE'
Usage: bcm_dsc_scan.sh [-u <0|1>] [-o <outdir>] [-f <logname>] [-F]
[-c <copydir>] [-C] [-d <sec>] [-n] [-h]
-u <unit> 只掃指定 unit。省略則 unit 0 與 1 都掃。
-o <dir> log 輸出目錄 (default: <script_dir>/log)
-f <name> 自訂 log 檔名(僅檔名,不含路徑)
佔位符: %u -> u0/u1 , %t -> YYYYmmdd_HHMMSS
default: dsc_raw_%u_%t.log
-F 允許覆蓋既有同名檔案 (default: 自動加序號 _1 / _2 ...)
-c <dir> 掃描後複製目的地 (default: /host/hw-eval/jobs)
-C 不複製
-d <sec> 每筆 phydiag 之間的間隔 (default: 0.2)
-n dry-run,列出展開後的 port 清單與指令,不實際執行
-h 顯示此說明
USAGE
exit 1
}
while getopts "u:o:f:Fc:Cd:nh" opt; do
case "$opt" in
u)
case "$OPTARG" in
0|1) UNITS="$OPTARG" ;;
*) echo "ERROR: -u 只接受 0 或 1 (got: $OPTARG)" >&2; exit 2 ;;
esac
;;
o) OUTDIR="$OPTARG" ;;
f)
case "$OPTARG" in
"") echo "ERROR: -f 不可為空" >&2; exit 2 ;;
*/*) echo "ERROR: -f 只接受檔名,路徑請用 -o (got: $OPTARG)" >&2; exit 2 ;;
*) LOGNAME="$OPTARG" ;;
esac
;;
F) FORCE=1 ;;
c) COPYDIR="$OPTARG" ;;
C) DOCOPY=0 ;;
d) DELAY="$OPTARG" ;;
n) DRYRUN=1 ;;
*) usage ;;
esac
done
mkdir -p "$OUTDIR" 2>/dev/null || { echo "ERROR: cannot create outdir: $OUTDIR" >&2; exit 2; }
[ -w "$OUTDIR" ] || { echo "ERROR: outdir not writable: $OUTDIR" >&2; exit 2; }
# ----------------------------------------------------------------------------
# sleep 是否支援小數秒(busybox sleep 可能只吃整數)
# ----------------------------------------------------------------------------
if ! sleep "$DELAY" 2>/dev/null; then
echo "WARN: sleep '$DELAY' 不支援,改用 sleep 1" >&2
DELAY="1"
fi
# ----------------------------------------------------------------------------
# 展開 port 清單。100G 先展開,重複者以 100G 為準。
# 輸出格式: "<port>,<speed>",依 port 數值排序。
# ----------------------------------------------------------------------------
PORTFILE=$(mktemp) || { echo "ERROR: mktemp failed" >&2; exit 2; }
trap 'rm -f "$PORTFILE"' EXIT
trap 'echo "" >&2; echo "### DSC_SCAN_DONE rc=130 (INTERRUPTED)" >&2; rm -f "$PORTFILE"; exit 130' INT TERM
awk -v a="$PORTS_100G" -v b="$PORTS_200G" '
function expand(s, spd, n, i, j, p, k, lo, hi) {
n = split(s, p, /[ \t]+/)
for (i = 1; i <= n; i++) {
if (p[i] == "") continue
if (split(p[i], k, "-") == 2) { lo = k[1] + 0; hi = k[2] + 0 }
else { lo = p[i] + 0; hi = lo }
if (hi < lo) { printf "ERROR: bad range %s\n", p[i] > "/dev/stderr"; continue }
for (j = lo; j <= hi; j++) if (!(j in SPD)) SPD[j] = spd
}
}
BEGIN {
expand(a, "100G") # 先展開 100G,重複的 port 由 100G 取得
expand(b, "200G")
for (p in SPD) print p "," SPD[p]
}' | sort -t, -k1,1n > "$PORTFILE"
TOTAL=$(wc -l < "$PORTFILE" | tr -d ' ')
[ "$TOTAL" -gt 0 ] || { echo "ERROR: port list is empty" >&2; exit 2; }
N100=$(grep -c ',100G$' "$PORTFILE" || true)
N200=$(grep -c ',200G$' "$PORTFILE" || true)
echo "Script dir : $SCRIPT_DIR" >&2
echo "Log dir : $OUTDIR" >&2
echo "Port list : $TOTAL ports (200G x2: $N200, 100G: $N100)" >&2
echo "Units : $UNITS" >&2
echo "Delay : ${DELAY}s" >&2
# ----------------------------------------------------------------------------
# Dry-run
# ----------------------------------------------------------------------------
if [ "$DRYRUN" -eq 1 ]; then
for U in $UNITS; do
echo "--- unit $U ---"
while IFS=, read -r P SPD; do
printf 'bcmcmd -n %s -c "dsh -c '\''phydiag %s dsc'\''" # %s\n' "$U" "$P" "$SPD"
done < "$PORTFILE"
done
echo "### DSC_SCAN_DONE rc=0 (dry-run)" >&2
exit 0
fi
command -v bcmcmd > /dev/null 2>&1 || {
echo "ERROR: bcmcmd not found in PATH" >&2
echo "### DSC_SCAN_DONE rc=3" >&2
exit 3
}
# stderr 是 tty 才用 \r 進度條;被導向檔案(Tera Term logopen)時改逐行輸出
if [ -t 2 ]; then TTY=1; else TTY=0; fi
# ----------------------------------------------------------------------------
# Log 檔名處理
# ----------------------------------------------------------------------------
# 純 POSIX 的字串取代,避免 sed 對檔名中的特殊字元誤判
replace_all() { # $1=haystack $2=needle $3=replacement
_s=$1; _n=$2; _r=$3; _out=""
while :; do
case "$_s" in
*"$_n"*)
_out="${_out}${_s%%"$_n"*}${_r}"
_s=${_s#*"$_n"}
;;
*) _out="${_out}${_s}"; break ;;
esac
done
printf '%s' "$_out"
}
# 依 unit 組出 log 檔名。未指定 -f 時使用預設命名。
build_logname() { # $1=unit
_u=$1
_n=${LOGNAME:-dsc_raw_%u_%t.log}
case "$_n" in *%u*) _has_u=1 ;; *) _has_u=0 ;; esac
_n=$(replace_all "$_n" '%u' "u${_u}")
_n=$(replace_all "$_n" '%t' "$TS")
# 掃兩個 unit 但檔名沒有 %u,第二個 unit 會覆蓋第一個,故自動加上 _u<unit>
if [ "$_has_u" -eq 0 ] && [ "$MULTI" -eq 1 ]; then
case "$_n" in
*.*) _n="${_n%.*}_u${_u}.${_n##*.}" ;;
*) _n="${_n}_u${_u}" ;;
esac
fi
printf '%s' "$_n"
}
# 檔案已存在時自動加序號,避免覆蓋掉先前的掃描結果(-F 可強制覆蓋)
uniquify() { # $1=filename
_f=$1
[ -e "${OUTDIR}/${_f}" ] || { printf '%s' "$_f"; return; }
if [ "$FORCE" -eq 1 ]; then
echo "WARN: 覆蓋既有檔案 ${OUTDIR}/${_f}" >&2
printf '%s' "$_f"; return
fi
case "$_f" in
*.*) _b=${_f%.*}; _e=".${_f##*.}" ;;
*) _b=$_f; _e="" ;;
esac
_i=1
while [ -e "${OUTDIR}/${_b}_${_i}${_e}" ]; do _i=$((_i + 1)); done
echo "WARN: ${_f} 已存在,改用 ${_b}_${_i}${_e}(-F 可強制覆蓋)" >&2
printf '%s' "${_b}_${_i}${_e}"
}
MULTI=0
[ "$(echo "$UNITS" | wc -w | tr -d ' ')" -gt 1 ] && MULTI=1
# ----------------------------------------------------------------------------
# Scan
# ----------------------------------------------------------------------------
TS=$(date +%Y%m%d_%H%M%S)
RC=0
LOGS=""
for U in $UNITS; do
LOG="${OUTDIR}/$(uniquify "$(build_logname "$U")")"
: > "$LOG" || { echo "ERROR: cannot write $LOG" >&2; exit 2; }
LOGS="$LOGS $LOG"
printf '### SCAN_BEGIN unit=%s time=%s total=%s script=bcm_dsc_scan_V1.1.0\n' \
"$U" "$(date '+%Y-%m-%d %H:%M:%S')" "$TOTAL" >> "$LOG"
echo "[unit $U] scanning $TOTAL ports -> $LOG" >&2
SEQ=0
while IFS=, read -r P SPD; do
SEQ=$((SEQ + 1))
printf '### PORT unit=%s port=%s speed=%s seq=%s/%s\n' "$U" "$P" "$SPD" "$SEQ" "$TOTAL" >> "$LOG"
bcmcmd -n "$U" -c "dsh -c 'phydiag $P dsc'" >> "$LOG" 2>&1 || RC=1
if [ "$TTY" -eq 1 ]; then
printf '\r[unit %s] %s/%s port=%-5s' "$U" "$SEQ" "$TOTAL" "$P" >&2
elif [ $((SEQ % PROGRESS_EVERY)) -eq 0 ] || [ "$SEQ" -eq "$TOTAL" ]; then
printf '[unit %s] %s/%s port=%s\n' "$U" "$SEQ" "$TOTAL" "$P" >&2
fi
sleep "$DELAY"
done < "$PORTFILE"
[ "$TTY" -eq 1 ] && printf '\n' >&2
printf '### SCAN_END unit=%s time=%s\n' "$U" "$(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG"
# 概略檢查:每筆 PORT marker 應對應一個 dsc 區塊
MARK=$(grep -c '^### PORT ' "$LOG" || true)
DUMP=$(grep -c 'SERDES DISPLAY DIAG DATA END' "$LOG" || true)
if [ "$MARK" -ne "$DUMP" ]; then
echo "WARN: unit $U markers=$MARK dsc_blocks=$DUMP (有 port 未取得完整 dump)" >&2
RC=1
fi
echo "Saved: $LOG (${DUMP}/${MARK} dumps)" >&2
done
# ----------------------------------------------------------------------------
# Copy to job directory
# ----------------------------------------------------------------------------
if [ "$DOCOPY" -eq 1 ]; then
if mkdir -p "$COPYDIR" 2>/dev/null && [ -w "$COPYDIR" ]; then
for L in $LOGS; do
if cp -p "$L" "$COPYDIR/"; then
echo "Copied: $COPYDIR/$(basename "$L")" >&2
else
echo "ERROR: copy failed: $L -> $COPYDIR" >&2
RC=1
fi
done
sync
else
echo "ERROR: copydir not writable: $COPYDIR (log 仍保留在 $OUTDIR)" >&2
RC=1
fi
fi
echo "### DSC_SCAN_DONE rc=${RC}" >&2
exit "$RC"