Explorar el Código

Add FPGA PWM trace hardware CI

botao hace 1 semana
padre
commit
a58fb6d1ba

+ 2 - 0
.gitignore

@@ -12,3 +12,5 @@ output/
 *.tsm
 *.vvp
 *.vcd
+/fpga/fpga_pwm_trace_project/log/
+/fpga/fpga_pwm_trace_project/.ci.lock

+ 245 - 0
fpga/fpga_pwm_trace_project/README_中文.md

@@ -0,0 +1,245 @@
+# EF2L45 8 路设备 Trace 工程 V2
+
+## 1. 这版实际要做什么
+
+这块 `EF2L45BG256B MINI V2.0` FPGA 板继续作为**被动 Trace 测量板**,不去驱动被测设备,只监听 8 根数字控制/状态线:
+
+- 1 路加热:`trace_heater`
+- 1 路风扇:`trace_fan`
+- 3 路电机:`trace_motor1 / 2 / 3`
+- 3 路限位器:`trace_limit1 / 2 / 3`
+
+UART 继续放在 **J5**。全部 8 路 Trace 输入集中放在 **J6**,并且选择 J6 最前面的连续 8 个普通单端 I/O,避免把 Trace 信号散到不同排针。
+
+> 重要:这里的 `motor1/2/3` 每个目前代表**一根数字 Trace 线**。如果实际电机接口后面确定为 `STEP + DIR`、`PWM + DIR` 或更多信号,那么每个电机就不再是一根线,需要重新扩展通道数量;本版本严格按照当前“1 加热 + 1 风扇 + 3 电机 + 3 限位 = 8 根 Trace 线”的要求实现。
+
+## 2. J5 / J6 实际接线
+
+根据 `EF2L45BG256B_MINI_V2.0` 原理图:
+
+| 功能 | 排针脚位 | 原理图网络 | FPGA LOCATION | 方向 |
+|---|---:|---|---|---|
+| UART TX | J5-5 | BANK0_IO1_2 | C4 | FPGA 输出 |
+| 加热 Trace | J6-3 | BANK2_IO2_0 | T4 | FPGA 输入 |
+| 风扇 Trace | J6-4 | BANK2_IO2_1 | R5 | FPGA 输入 |
+| 电机 1 Trace | J6-5 | BANK2_IO2_2 | T6 | FPGA 输入 |
+| 电机 2 Trace | J6-6 | BANK2_IO2_3 | R7 | FPGA 输入 |
+| 电机 3 Trace | J6-7 | BANK3_IO2_4 | M1 | FPGA 输入 |
+| 限位 1 Trace | J6-8 | BANK3_IO2_5 | N1 | FPGA 输入 |
+| 限位 2 Trace | J6-9 | BANK3_IO2_6 | P1 | FPGA 输入 |
+| 限位 3 Trace | J6-10 | BANK3_IO2_7 | R1 | FPGA 输入 |
+
+J5/J6 的 `1、2` 脚是 `+3V3`,`39、40` 脚是 `GND`。UART 使用时把 `J5-5` 接 USB-UART/CP2102 的 `RXD`,并把地接到 J5 或 J6 的 GND。
+
+### 电压安全
+
+J6 上这些是 FPGA 的原始 `LVCMOS33` 输入。**只能直接接 3.3 V 逻辑电平,不能把 5 V、12 V、24 V 的加热器/风扇/电机电源线或工业限位线直接接入 FPGA。**
+
+如果被测控制板上的信号高于 3.3 V,必须先经过适合该接口的电平转换、分压/钳位、比较器、光耦或数字隔离器。Trace 板和被测板如果不是隔离连接,还需要共地。这里监听的是“控制逻辑信号”,不是负载电源本身。
+
+## 3. 五路执行器 Trace 的测量内容
+
+对加热、风扇、三个电机,FPGA 每一路都使用独立的 `pwm_trace_measure`:
+
+```text
+edge_count   = 上升沿累计次数
+period_ticks = 相邻上升沿之间的 25 MHz 时钟数
+high_ticks   = 对应周期内的高电平时钟数
+signal_level = 当前同步后的原始逻辑电平
+```
+
+25 MHz 下主机换算:
+
+```text
+frequency_hz = 25_000_000 / period_ticks
+duty_percent = high_ticks * 100 / period_ticks
+```
+
+原来的测量核心有 100 ms 无边沿超时:长时间没有新的上升沿时,`period/high` 会归零,表示“当前没有有效周期测量”。V2 额外把 `signal_level` 一起传回,所以可以区分:
+
+```text
+静态 LOW  -> level=0, period=0
+静态 HIGH -> level=1, period=0
+正常 PWM  -> level=当前瞬时电平,同时 period/high 有效
+```
+
+这一点对加热尤其重要,因为 0% 和 100% 输出都可能没有持续边沿,不能只靠 PWM 周期判断。
+
+## 4. 三个限位器的处理
+
+限位器不按 PWM 去解释。`limit_trace_capture` 对 3 路限位做:
+
+```text
+两级同步
+当前 RAW 电平
+自上次快照以后是否见过上升沿
+自上次快照以后是否见过下降沿
+```
+
+固件**不假设限位器是高有效还是低有效**。上位机先看到原始 0/1;等实际控制板的限位电路极性确认以后,再在上位机把 0/1 翻译成 `TRIGGERED / RELEASED`。这样不会在 FPGA 里提前猜错极性。
+
+## 5. UART 协议
+
+仍沿用原工程的 16-byte `0x82` Trace 包:
+
+```text
+Byte0     A5
+Byte1     5A
+Byte2     type = 82
+Byte3-4   sequence     big-endian
+Byte5-6   event_id     big-endian
+Byte7-10  timestamp_ms big-endian
+Byte11-14 data         big-endian
+Byte15    CRC8
+```
+
+CRC-8:
+
+```text
+polynomial = 0x07
+init       = 0x00
+覆盖 bytes 2..14
+```
+
+五路测量事件:
+
+```text
+0x5101 / 5102 / 5103 = heater count / period / high
+0x5111 / 5112 / 5113 = fan    count / period / high
+0x5121 / 5122 / 5123 = motor1 count / period / high
+0x5131 / 5132 / 5133 = motor2 count / period / high
+0x5141 / 5142 / 5143 = motor3 count / period / high
+0x5200               = digital status
+```
+
+`0x5200 data` 位定义:
+
+```text
+bit 0       heater raw level
+bit 1       fan raw level
+bit 2       motor1 raw level
+bit 3       motor2 raw level
+bit 4       motor3 raw level
+bit 5       limit1 raw level
+bit 6       limit2 raw level
+bit 7       limit3 raw level
+
+bits 12:8   heater..motor3 measurement_valid
+bits 17:13  heater..motor3 recent-edge/signal_present
+bits 20:18  limit1..3 rising-edge seen since previous snapshot
+bits 23:21  limit1..3 falling-edge seen since previous snapshot
+bits 31:24  reserved = 0
+```
+
+每一组快照一共 16 个包:5 路 × 3 个测量包 + 1 个状态包。
+
+## 6. 为什么报告周期从 10 ms 改成 50 ms
+
+UART 仍是 `115200 8N1`。16-byte 包实际串口线上每字节需要 10 bit(start + 8 data + stop),因此一组 16 包需要:
+
+```text
+16 packets × 16 bytes × 10 bits / 115200
+≈ 22.22 ms
+```
+
+旧的一路 Trace 只有 3 个包,10 ms 可以装下;现在 8 路以后如果仍然硬设 10 ms,新快照会在上一个 UART burst 还没发完时到来。为避免积压、丢快照和时间戳混乱,生产默认改为 **50 ms**,约 20 组快照/秒,同时保留足够串口余量。
+
+## 7. 工程结构
+
+```text
+rtl/
+  pwm_trace_measure.v       五路复用的 PWM/脉冲测量核心
+  limit_trace_capture.v     三路限位同步 + sticky edge
+  uart_tx.v
+  trace_packet_tx.v
+  pwm_trace_top.v           8 路顶层 + 16 包快照调度
+
+sim/
+  pwm_trace_measure_tb.v
+  limit_trace_capture_tb.v
+  pwm_trace_uart_tb.v       8 路全链路 UART/CRC/Event 测试
+
+host/
+  trace_uart_monitor.py
+  reference_selftest.py
+
+constraints/
+  pwm_trace.adc             J5/J6 已落实的真实引脚
+  pwm_trace.sdc
+
+scripts/
+  run_test.sh
+  build.sh
+  program.sh
+  td_build.tcl
+```
+
+## 8. 测试 / 编译 / 烧录
+
+主机参考计算先检查:
+
+```bash
+python3 host/reference_selftest.py
+```
+
+有 Icarus Verilog 时运行全部 testbench:
+
+```bash
+./scripts/run_test.sh
+```
+
+TD 4.6.7 命令行生成 bit:
+
+```bash
+./scripts/build.sh
+```
+
+输出:
+
+```text
+build/td/fpga_pwm_trace.bit
+```
+
+烧录继续使用之前真板已经验证成功的底层路径,不使用 `download -mode JTAG` 包装命令:
+
+```bash
+./scripts/program.sh
+```
+
+内部实际是:
+
+```text
+bit_to_vec -chip EF2L45B -m jtag_burst ...
+program -cable 0 -mode svf -spd 4 -p
+```
+
+## 9. Debian 读取 Trace
+
+```bash
+python3 host/trace_uart_monitor.py /dev/ttyUSB0
+```
+
+每 50 ms 左右会打印五路执行器的:
+
+```text
+raw level
+count
+valid / present
+frequency
+PWM duty
+period/high ticks
+```
+
+以及三个限位器的:
+
+```text
+RAW current level
+rise_seen
+fall_seen
+```
+
+`rise_seen/fall_seen` 可以帮助发现两个 50 ms 快照之间发生过、但到快照时已经恢复的短暂限位变化。
+
+## 10. 这版刻意没有做的事
+
+这仍然是被动 Trace,不是控制器:FPGA 不输出加热 PWM、不控制风扇、不驱动三个电机,也不替设备做限位停机。它只采集和上传真实控制板已经产生的信号。这样能把“设备控制逻辑”和“Trace/验证逻辑”彻底分开,避免为了监控而改变被测系统行为。

+ 203 - 0
fpga/fpga_pwm_trace_project/ci.sh

@@ -0,0 +1,203 @@
+#!/bin/bash
+set -euo pipefail
+
+PROJECT="$(cd "$(dirname "$0")" && pwd)"
+LOG_DIR="$PROJECT/log"
+
+TD_BIN="${TD_BIN:-/root/TD_4.6.7/TD_4.6.7_Linux/bin/td}"
+UART_DEV="${UART_DEV:-/dev/ttyUSB0}"
+TRACE_TIMEOUT="${TRACE_TIMEOUT:-10}"
+TRACE_MIN_SNAPSHOTS="${TRACE_MIN_SNAPSHOTS:-10}"
+
+LOCK_FILE="$PROJECT/.ci.lock"
+
+for cmd in python3 iverilog vvp flock sha256sum awk tee stty; do
+    command -v "$cmd" >/dev/null 2>&1 || {
+        echo "ERROR: missing command: $cmd"
+        exit 2
+    }
+done
+
+[ -x "$TD_BIN" ] || {
+    echo "ERROR: TD not executable: $TD_BIN"
+    exit 2
+}
+
+[ -e "$UART_DEV" ] || {
+    echo "ERROR: UART device missing: $UART_DEV"
+    exit 2
+}
+
+[ -f "$PROJECT/scripts/run_test.sh" ] || {
+    echo "ERROR: missing scripts/run_test.sh"
+    exit 2
+}
+
+[ -f "$PROJECT/scripts/build.sh" ] || {
+    echo "ERROR: missing scripts/build.sh"
+    exit 2
+}
+
+[ -f "$PROJECT/scripts/program.sh" ] || {
+    echo "ERROR: missing scripts/program.sh"
+    exit 2
+}
+
+[ -f "$PROJECT/host/reference_selftest.py" ] || {
+    echo "ERROR: missing host/reference_selftest.py"
+    exit 2
+}
+
+[ -f "$PROJECT/host/trace_ci_check.py" ] || {
+    echo "ERROR: missing host/trace_ci_check.py"
+    exit 2
+}
+
+# 防止两个 CI 同时操作同一块 FPGA
+exec 9>"$LOCK_FILE"
+
+flock -w 300 9 || {
+    echo "ERROR: FPGA CI is already running"
+    exit 3
+}
+
+# log/ 永远只代表最近一次 CI
+rm -rf "$LOG_DIR"
+mkdir -p "$LOG_DIR"
+
+# 清理 TD 以前自动产生的时间戳日志
+rm -f "$PROJECT"/td_*.log
+
+STARTED="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+
+GIT_SHA="LOCAL-NOT-GIT"
+GIT_DIRTY="unknown"
+
+if command -v git >/dev/null 2>&1 &&
+   git -C "$PROJECT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+
+    GIT_SHA="$(git -C "$PROJECT" rev-parse HEAD)"
+
+    if [ -n "$(git -C "$PROJECT" status --porcelain)" ]; then
+        GIT_DIRTY="yes"
+    else
+        GIT_DIRTY="no"
+    fi
+fi
+
+echo "=============================================="
+echo " FPGA PWM TRACE CI"
+echo "=============================================="
+echo "Project : $PROJECT"
+echo "TD      : $TD_BIN"
+echo "UART    : $UART_DEV"
+echo "Log     : $LOG_DIR"
+echo
+
+set +e
+
+(
+    set -euo pipefail
+
+    echo "=============================================="
+    echo "STAGE 1: TESTBENCH"
+    echo "=============================================="
+
+    cd "$PROJECT"
+    bash scripts/run_test.sh
+
+    echo
+    echo "=============================================="
+    echo "STAGE 2: REFERENCE SELFTEST"
+    echo "=============================================="
+
+    python3 host/reference_selftest.py
+
+    echo
+    echo "=============================================="
+    echo "STAGE 3: FPGA BUILD"
+    echo "=============================================="
+
+    TD_BIN="$TD_BIN" bash scripts/build.sh
+
+    echo
+    echo "=============================================="
+    echo "STAGE 4: FPGA PROGRAM"
+    echo "=============================================="
+
+    TD_BIN="$TD_BIN" bash scripts/program.sh
+
+    echo
+    echo "=============================================="
+    echo "STAGE 5: UART TRACE"
+    echo "=============================================="
+
+    sleep 2
+
+    echo "Configuring UART: $UART_DEV @ 115200 8N1 raw"
+    stty -F "$UART_DEV" \
+        raw -echo \
+        115200 cs8 -cstopb -parenb \
+        -ixon -ixoff -crtscts
+
+    python3 host/trace_ci_check.py \
+        "$UART_DEV" \
+        --timeout "$TRACE_TIMEOUT" \
+        --min-snapshots "$TRACE_MIN_SNAPSHOTS"
+
+) 2>&1 | tee "$LOG_DIR/ci.log"
+
+RC=${PIPESTATUS[0]}
+
+set -e
+
+BIT="$PROJECT/build/td/fpga_pwm_trace.bit"
+BIT_SHA256="N/A"
+
+if [ -s "$BIT" ]; then
+    BIT_SHA256="$(sha256sum "$BIT" | awk '{print $1}')"
+fi
+
+ENDED="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+
+if [ "$RC" -eq 0 ]; then
+    RESULT="PASS"
+else
+    RESULT="FAIL"
+fi
+
+cat > "$LOG_DIR/metadata.txt" <<META
+project=$PROJECT
+git_sha=$GIT_SHA
+git_dirty=$GIT_DIRTY
+host=$(hostname)
+td_bin=$TD_BIN
+uart_dev=$UART_DEV
+bit=$BIT
+bit_sha256=$BIT_SHA256
+started=$STARTED
+ended=$ENDED
+exit_code=$RC
+result=$RESULT
+META
+
+# TD 每次运行自己产生的 td_时间.log 不保留
+# 完整 CI 输出已经统一保存在 log/ci.log
+rm -f "$PROJECT"/td_*.log
+
+echo
+echo "=============================================="
+
+if [ "$RC" -eq 0 ]; then
+    echo " CI PASS"
+else
+    echo " CI FAIL"
+fi
+
+echo "=============================================="
+echo "Result : $RESULT"
+echo "Log    : $LOG_DIR/ci.log"
+echo "Meta   : $LOG_DIR/metadata.txt"
+echo "Bit    : $BIT"
+
+exit "$RC"

+ 31 - 0
fpga/fpga_pwm_trace_project/constraints/pwm_trace.adc

@@ -0,0 +1,31 @@
+# EF2L45BG256B MINI V2.0 - 8-channel passive Trace mapping
+#
+# UART remains on J5:
+#   uart_tx = C4 = BANK0_IO1_2 = J5 pin 5
+#
+# J6 trace inputs use the first eight general-purpose signal positions:
+#   J6-3  BANK2_IO2_0 -> T4  heater
+#   J6-4  BANK2_IO2_1 -> R5  fan
+#   J6-5  BANK2_IO2_2 -> T6  motor1
+#   J6-6  BANK2_IO2_3 -> R7  motor2
+#   J6-7  BANK3_IO2_4 -> M1  motor3
+#   J6-8  BANK3_IO2_5 -> N1  limit1
+#   J6-9  BANK3_IO2_6 -> P1  limit2
+#   J6-10 BANK3_IO2_7 -> R1  limit3
+#
+# J6 pins 1/2 are +3V3 and pins 39/40 are GND.  The signal pins below are raw
+# 3.3-V FPGA inputs; never feed 5/12/24 V directly into them.
+
+set_pin_assignment {sys_clk}        {LOCATION=J1;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {reset_n}        {LOCATION=N6;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {uart_tx}        {LOCATION=C4;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {led_activity_n} {LOCATION=H16; IOSTANDARD=LVCMOS33;}
+
+set_pin_assignment {trace_heater}   {LOCATION=T4;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {trace_fan}      {LOCATION=R5;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {trace_motor1}   {LOCATION=T6;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {trace_motor2}   {LOCATION=R7;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {trace_motor3}   {LOCATION=M1;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {trace_limit1}   {LOCATION=N1;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {trace_limit2}   {LOCATION=P1;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {trace_limit3}   {LOCATION=R1;  IOSTANDARD=LVCMOS33;}

+ 16 - 0
fpga/fpga_pwm_trace_project/constraints/pwm_trace.adc.template

@@ -0,0 +1,16 @@
+# This template now matches the verified EF2L45BG256B MINI V2.0 schematic.
+# See README_中文.md for the physical J5/J6 pin table and voltage warning.
+
+set_pin_assignment {sys_clk}        {LOCATION=J1;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {reset_n}        {LOCATION=N6;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {uart_tx}        {LOCATION=C4;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {led_activity_n} {LOCATION=H16; IOSTANDARD=LVCMOS33;}
+
+set_pin_assignment {trace_heater}   {LOCATION=T4;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {trace_fan}      {LOCATION=R5;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {trace_motor1}   {LOCATION=T6;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {trace_motor2}   {LOCATION=R7;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {trace_motor3}   {LOCATION=M1;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {trace_limit1}   {LOCATION=N1;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {trace_limit2}   {LOCATION=P1;  IOSTANDARD=LVCMOS33;}
+set_pin_assignment {trace_limit3}   {LOCATION=R1;  IOSTANDARD=LVCMOS33;}

+ 2 - 0
fpga/fpga_pwm_trace_project/constraints/pwm_trace.sdc

@@ -0,0 +1,2 @@
+# 25 MHz system clock
+create_clock -name sys_clk -period 40 -waveform {0 20} [get_ports sys_clk]

+ 38 - 0
fpga/fpga_pwm_trace_project/fpga_pwm_trace.al

@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Project>
+    <Project_Created_Time>2026-09-15 17:40:00</Project_Created_Time>
+    <TD_Version>4.6.64949</TD_Version>
+    <UCode>00000000</UCode>
+
+    <Name>fpga_pwm_trace</Name>
+
+    <HardWare>
+        <Family>EF2</Family>
+        <Device>EF2L45BG256B</Device>
+    </HardWare>
+
+    <Source_Files>
+        <Verilog>
+            <File>rtl/pwm_trace_measure.v</File>
+            <File>rtl/limit_trace_capture.v</File>
+            <File>rtl/uart_tx.v</File>
+            <File>rtl/trace_packet_tx.v</File>
+            <File>rtl/pwm_trace_top.v</File>
+        </Verilog>
+
+        <ADC_FILE>constraints/pwm_trace.adc</ADC_FILE>
+        <SDC_FILE>constraints/pwm_trace.sdc</SDC_FILE>
+        <CWC_FILE/>
+    </Source_Files>
+
+    <TOP_MODULE>
+        <LABEL/>
+        <MODULE>pwm_trace_top</MODULE>
+        <CREATEINDEX>auto</CREATEINDEX>
+    </TOP_MODULE>
+
+    <Project_Settings>
+        <Current_Step>0</Current_Step>
+        <Step_Status>false</Step_Status>
+    </Project_Settings>
+</Project>

+ 57 - 0
fpga/fpga_pwm_trace_project/host/reference_selftest.py

@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+
+CLOCK_HZ = 25_000_000
+
+
+def crc8(data):
+    crc = 0
+    for b in data:
+        crc ^= b
+        for _ in range(8):
+            if crc & 0x80:
+                crc = ((crc << 1) & 0xFF) ^ 0x07
+            else:
+                crc = (crc << 1) & 0xFF
+    return crc
+
+
+def measure(period, high):
+    return CLOCK_HZ / period, 100.0 * high / period
+
+
+cases = [
+    (1000, 250, 25_000.0, 25.0),
+    (1250, 500, 20_000.0, 40.0),
+    (2500, 1250, 10_000.0, 50.0),
+    (5000, 1000, 5_000.0, 20.0),
+    (10000, 7500, 2_500.0, 75.0),
+]
+
+for period, high, want_f, want_d in cases:
+    f, d = measure(period, high)
+    assert abs(f - want_f) < 1e-9
+    assert abs(d - want_d) < 1e-9
+
+payload = bytes.fromhex("82 00 01 51 02 00 00 00 64 00 00 04 E2")
+c = crc8(payload)
+assert 0 <= c <= 255
+
+# Status packing reference:
+# levels 0b11000000 -> L3=1,L2=1,L1=0; valid/present all 5 set;
+# rise_seen=111; fall_seen=001.
+status = 0
+status |= 0b110 << 5
+status |= 0b11111 << 8
+status |= 0b11111 << 13
+status |= 0b111 << 18
+status |= 0b001 << 21
+assert ((status >> 5) & 0x7) == 0b110
+assert ((status >> 8) & 0x1F) == 0b11111
+assert ((status >> 13) & 0x1F) == 0b11111
+assert ((status >> 18) & 0x7) == 0b111
+assert ((status >> 21) & 0x7) == 0b001
+
+print("PASS: five channel frequency/duty calculations")
+print("PASS: CRC-8/0x07 calculation")
+print("PASS: digital-status bit layout")
+print("HOST REFERENCE SELF-TEST PASSED")

+ 167 - 0
fpga/fpga_pwm_trace_project/host/trace_ci_check.py

@@ -0,0 +1,167 @@
+#!/usr/bin/env python3
+import argparse
+import os
+import select
+import sys
+import termios
+import time
+
+PKT_TRACE = 0x82
+EVT_STATUS = 0x5200
+EXPECTED_EVENTS = {
+    0x5101, 0x5102, 0x5103,
+    0x5111, 0x5112, 0x5113,
+    0x5121, 0x5122, 0x5123,
+    0x5131, 0x5132, 0x5133,
+    0x5141, 0x5142, 0x5143,
+    EVT_STATUS,
+}
+
+
+def crc8(data: bytes) -> int:
+    crc = 0
+    for b in data:
+        crc ^= b
+        for _ in range(8):
+            crc = (((crc << 1) & 0xFF) ^ 0x07) if (crc & 0x80) else ((crc << 1) & 0xFF)
+    return crc
+
+
+def open_serial(dev: str):
+    fd = os.open(dev, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
+    attrs = termios.tcgetattr(fd)
+    attrs[0] = 0
+    attrs[1] = 0
+    attrs[2] = termios.CS8 | termios.CLOCAL | termios.CREAD
+    attrs[3] = 0
+    attrs[4] = termios.B115200
+    attrs[5] = termios.B115200
+    attrs[6][termios.VMIN] = 0
+    attrs[6][termios.VTIME] = 0
+    termios.tcsetattr(fd, termios.TCSANOW, attrs)
+    termios.tcflush(fd, termios.TCIOFLUSH)
+    return fd
+
+
+def parse_args():
+    p = argparse.ArgumentParser(description="Finite CI smoke test for 8-channel FPGA Trace UART")
+    p.add_argument("device", nargs="?", default="/dev/ttyUSB0")
+    p.add_argument("--timeout", type=float, default=8.0)
+    p.add_argument("--min-snapshots", type=int, default=10)
+    p.add_argument("--max-crc-errors", type=int, default=20)
+    return p.parse_args()
+
+
+def main() -> int:
+    args = parse_args()
+    print(f"CI TRACE: opening {args.device} @ 115200 8N1", flush=True)
+    deadline = time.monotonic() + args.timeout
+
+    try:
+        fd = open_serial(args.device)
+    except OSError as e:
+        print(f"CI TRACE FAIL: cannot open {args.device}: {e}", file=sys.stderr)
+        return 2
+
+    buf = bytearray()
+    snapshots = {}
+    complete = 0
+    crc_errors = 0
+    packets = 0
+    last_ts = None
+
+    try:
+        while time.monotonic() < deadline and complete < args.min_snapshots:
+            remain = max(0.0, deadline - time.monotonic())
+            readable, _, _ = select.select([fd], [], [], min(0.25, remain))
+            if not readable:
+                continue
+            try:
+                chunk = os.read(fd, 4096)
+            except BlockingIOError:
+                continue
+            if not chunk:
+                continue
+            buf.extend(chunk)
+
+            while True:
+                pos = buf.find(b"\xA5\x5A")
+                if pos < 0:
+                    if len(buf) > 1:
+                        del buf[:-1]
+                    break
+                if pos:
+                    del buf[:pos]
+                if len(buf) < 16:
+                    break
+
+                p = bytes(buf[:16])
+                del buf[:16]
+
+                want = crc8(p[2:15])
+                if p[15] != want:
+                    crc_errors += 1
+                    # Re-synchronize conservatively in case a false A5 5A was seen.
+                    buf[:0] = p[1:]
+                    continue
+
+                packets += 1
+                if p[2] != PKT_TRACE:
+                    continue
+
+                event_id = (p[5] << 8) | p[6]
+                timestamp = int.from_bytes(p[7:11], "big")
+                if event_id not in EXPECTED_EVENTS:
+                    continue
+
+                snap = snapshots.setdefault(timestamp, set())
+                snap.add(event_id)
+
+                if event_id == EVT_STATUS:
+                    missing = EXPECTED_EVENTS - snap
+                    if not missing:
+                        if last_ts is not None and timestamp <= last_ts:
+                            print(
+                                f"CI TRACE FAIL: non-increasing timestamp {timestamp} after {last_ts}",
+                                file=sys.stderr,
+                            )
+                            return 3
+                        complete += 1
+                        last_ts = timestamp
+                        print(
+                            f"CI TRACE snapshot {complete}/{args.min_snapshots}: "
+                            f"t={timestamp} ms, 16/16 events, CRC OK",
+                            flush=True,
+                        )
+                    snapshots.pop(timestamp, None)
+
+                if len(snapshots) > 64:
+                    for old in sorted(snapshots)[:32]:
+                        snapshots.pop(old, None)
+
+        if complete < args.min_snapshots:
+            print(
+                f"CI TRACE FAIL: only {complete} complete snapshots in {args.timeout:.1f}s "
+                f"({packets} CRC-valid packets, {crc_errors} CRC errors)",
+                file=sys.stderr,
+            )
+            return 4
+        if crc_errors > args.max_crc_errors:
+            print(
+                f"CI TRACE FAIL: CRC errors {crc_errors} exceed allowed {args.max_crc_errors}",
+                file=sys.stderr,
+            )
+            return 5
+
+        print(
+            f"CI TRACE PASS: {complete} complete 8-channel snapshots, "
+            f"{packets} CRC-valid packets, {crc_errors} CRC errors",
+            flush=True,
+        )
+        return 0
+    finally:
+        os.close(fd)
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 201 - 0
fpga/fpga_pwm_trace_project/host/trace_uart_monitor.py

@@ -0,0 +1,201 @@
+#!/usr/bin/env python3
+import os
+import sys
+import termios
+
+CLOCK_HZ = 25_000_000
+PKT_TRACE = 0x82
+EVT_STATUS = 0x5200
+
+CHANNELS = {
+    "heater": {"count": 0x5101, "period": 0x5102, "high": 0x5103, "bit": 0},
+    "fan":    {"count": 0x5111, "period": 0x5112, "high": 0x5113, "bit": 1},
+    "motor1": {"count": 0x5121, "period": 0x5122, "high": 0x5123, "bit": 2},
+    "motor2": {"count": 0x5131, "period": 0x5132, "high": 0x5133, "bit": 3},
+    "motor3": {"count": 0x5141, "period": 0x5142, "high": 0x5143, "bit": 4},
+}
+
+EVENT_TO_FIELD = {}
+for name, desc in CHANNELS.items():
+    EVENT_TO_FIELD[desc["count"]] = (name, "count")
+    EVENT_TO_FIELD[desc["period"]] = (name, "period")
+    EVENT_TO_FIELD[desc["high"]] = (name, "high")
+
+
+def crc8(data: bytes) -> int:
+    crc = 0
+    for b in data:
+        crc ^= b
+        for _ in range(8):
+            if crc & 0x80:
+                crc = ((crc << 1) & 0xFF) ^ 0x07
+            else:
+                crc = (crc << 1) & 0xFF
+    return crc
+
+
+def open_serial(dev: str):
+    fd = os.open(dev, os.O_RDWR | os.O_NOCTTY)
+    attrs = termios.tcgetattr(fd)
+    attrs[0] = 0
+    attrs[1] = 0
+    attrs[2] = termios.CS8 | termios.CLOCAL | termios.CREAD
+    attrs[3] = 0
+    attrs[4] = termios.B115200
+    attrs[5] = termios.B115200
+    attrs[6][termios.VMIN] = 1
+    attrs[6][termios.VTIME] = 0
+    termios.tcsetattr(fd, termios.TCSANOW, attrs)
+    termios.tcflush(fd, termios.TCIOFLUSH)
+    return fd
+
+
+def read_exact(fd: int, n: int) -> bytes:
+    out = bytearray()
+    while len(out) < n:
+        chunk = os.read(fd, n - len(out))
+        if not chunk:
+            raise RuntimeError("serial device returned EOF")
+        out.extend(chunk)
+    return bytes(out)
+
+
+def read_packet(fd: int) -> bytes:
+    state = 0
+    while True:
+        b = read_exact(fd, 1)[0]
+        if state == 0:
+            if b == 0xA5:
+                state = 1
+        else:
+            if b == 0x5A:
+                return bytes([0xA5, 0x5A]) + read_exact(fd, 14)
+            state = 1 if b == 0xA5 else 0
+
+
+def empty_snapshot():
+    return {
+        "channels": {
+            name: {"count": None, "period": None, "high": None}
+            for name in CHANNELS
+        },
+        "status": None,
+    }
+
+
+def bit(value: int, index: int) -> int:
+    return (value >> index) & 1
+
+
+def print_snapshot(timestamp: int, snap: dict):
+    status = snap["status"]
+    if status is None:
+        return
+
+    levels = status & 0xFF
+    valid = (status >> 8) & 0x1F
+    present = (status >> 13) & 0x1F
+    limit_rise = (status >> 18) & 0x07
+    limit_fall = (status >> 21) & 0x07
+
+    print(f"t={timestamp:10d} ms")
+    for name, desc in CHANNELS.items():
+        m = snap["channels"][name]
+        b = desc["bit"]
+        level_text = "HIGH" if bit(levels, b) else "LOW "
+        is_valid = bool(bit(valid, b))
+        is_present = bool(bit(present, b))
+        period = m["period"]
+        high = m["high"]
+        count = m["count"]
+
+        if is_valid and period not in (None, 0) and high is not None:
+            freq = CLOCK_HZ / period
+            duty = 100.0 * high / period
+            metric = (
+                f"freq={freq:11.3f} Hz  duty={duty:7.3f}%  "
+                f"period={period:10d}  high={high:10d}"
+            )
+        else:
+            metric = "freq=          --      duty=     --    period=         0  high=         0"
+
+        count_text = "--" if count is None else str(count)
+        print(
+            f"  {name:7s} level={level_text} present={int(is_present)} "
+            f"valid={int(is_valid)} count={count_text:>10s}  {metric}"
+        )
+
+    l1 = bit(levels, 5)
+    l2 = bit(levels, 6)
+    l3 = bit(levels, 7)
+    print(
+        "  limits  RAW "
+        f"L1={l1} L2={l2} L3={l3}  "
+        f"rise_seen={limit_rise:03b} fall_seen={limit_fall:03b}"
+    )
+    print()
+
+
+def main():
+    dev = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyUSB0"
+    print(f"Opening {dev} @ 115200 8N1")
+    print("Waiting for 8-channel FPGA Trace packets...")
+    print("Limit levels are RAW electrical levels; active polarity is not assumed.")
+    print()
+
+    fd = open_serial(dev)
+    snapshots = {}
+
+    try:
+        while True:
+            p = read_packet(fd)
+
+            pkt_type = p[2]
+            seq = (p[3] << 8) | p[4]
+            event_id = (p[5] << 8) | p[6]
+            timestamp = int.from_bytes(p[7:11], "big")
+            data = int.from_bytes(p[11:15], "big")
+            got_crc = p[15]
+            want_crc = crc8(p[2:15])
+
+            if got_crc != want_crc:
+                print(
+                    f"CRC ERROR seq={seq} got=0x{got_crc:02X} "
+                    f"expected=0x{want_crc:02X}"
+                )
+                continue
+
+            if pkt_type != PKT_TRACE:
+                print(
+                    f"OTHER type=0x{pkt_type:02X} seq={seq} "
+                    f"event=0x{event_id:04X} data={data}"
+                )
+                continue
+
+            snap = snapshots.setdefault(timestamp, empty_snapshot())
+
+            if event_id in EVENT_TO_FIELD:
+                name, field = EVENT_TO_FIELD[event_id]
+                snap["channels"][name][field] = data
+            elif event_id == EVT_STATUS:
+                snap["status"] = data
+                print_snapshot(timestamp, snap)
+                snapshots.pop(timestamp, None)
+            else:
+                print(
+                    f"UNKNOWN TRACE t={timestamp}ms seq={seq} "
+                    f"event=0x{event_id:04X} data={data}"
+                )
+
+            if len(snapshots) > 100:
+                for old in sorted(snapshots)[:50]:
+                    snapshots.pop(old, None)
+
+    except KeyboardInterrupt:
+        print("\nStopped.")
+    finally:
+        os.close(fd)
+
+
+if __name__ == "__main__":
+    main()

+ 58 - 0
fpga/fpga_pwm_trace_project/rtl/limit_trace_capture.v

@@ -0,0 +1,58 @@
+`timescale 1ns/1ps
+
+// -----------------------------------------------------------------------------
+// limit_trace_capture
+//
+// Synchronizes the three raw limit-switch trace inputs and remembers whether a
+// rising/falling transition occurred since the last accepted report snapshot.
+// The trace firmware reports RAW electrical logic levels; it does not assume
+// whether the machine's limit switch is active-high or active-low.
+// -----------------------------------------------------------------------------
+module limit_trace_capture (
+    input  wire       clk,
+    input  wire       reset_n,
+    input  wire [2:0] limit_in,
+    input  wire       clear_seen,
+
+    output wire [2:0] level,
+    output reg  [2:0] rise_seen,
+    output reg  [2:0] fall_seen
+);
+
+    reg [2:0] limit_meta;
+    reg [2:0] limit_sync;
+    reg [2:0] limit_sync_d;
+
+    wire [2:0] rise_edge =  limit_sync & ~limit_sync_d;
+    wire [2:0] fall_edge = ~limit_sync &  limit_sync_d;
+
+    assign level = limit_sync;
+
+    always @(posedge clk or negedge reset_n) begin
+        if (!reset_n) begin
+            limit_meta   <= 3'b000;
+            limit_sync   <= 3'b000;
+            limit_sync_d <= 3'b000;
+        end else begin
+            limit_meta   <= limit_in;
+            limit_sync   <= limit_meta;
+            limit_sync_d <= limit_sync;
+        end
+    end
+
+    always @(posedge clk or negedge reset_n) begin
+        if (!reset_n) begin
+            rise_seen <= 3'b000;
+            fall_seen <= 3'b000;
+        end else if (clear_seen) begin
+            // If an edge occurs in the exact clear cycle, retain it for the
+            // following snapshot instead of losing it.
+            rise_seen <= rise_edge;
+            fall_seen <= fall_edge;
+        end else begin
+            rise_seen <= rise_seen | rise_edge;
+            fall_seen <= fall_seen | fall_edge;
+        end
+    end
+
+endmodule

+ 153 - 0
fpga/fpga_pwm_trace_project/rtl/pwm_trace_measure.v

@@ -0,0 +1,153 @@
+`timescale 1ns/1ps
+
+// -----------------------------------------------------------------------------
+// pwm_trace_measure
+//
+// Passive digital-signal measurement core.
+//
+// Measures:
+//   1) edge_count   : total rising-edge count since reset
+//   2) period_ticks : clocks between two consecutive rising edges
+//   3) high_ticks   : clocks that the input stayed high in that same period
+//
+// With a 25 MHz FPGA clock:
+//   frequency_hz = 25_000_000 / period_ticks
+//   duty_percent = high_ticks * 100 / period_ticks
+//
+// The observed signal is treated as asynchronous to clk, therefore a
+// two-flop synchronizer is used before edge detection.
+// -----------------------------------------------------------------------------
+module pwm_trace_measure #(
+    parameter integer CLK_HZ       = 25_000_000,
+    parameter integer NO_SIGNAL_MS = 100
+)(
+    input  wire        clk,
+    input  wire        reset_n,
+    input  wire        signal_in,
+
+    output reg  [31:0] edge_count,
+    output reg  [31:0] period_ticks,
+    output reg  [31:0] high_ticks,
+    output reg         measurement_valid,
+    output reg         signal_present,
+    output wire        signal_level
+);
+
+    localparam integer NO_SIGNAL_TICKS =
+        (CLK_HZ / 1000) * NO_SIGNAL_MS;
+
+    // Synchronizer + delayed sample for edge detection.
+    reg signal_meta;
+    reg signal_sync;
+    reg signal_sync_d;
+
+    wire rising_edge  =  signal_sync & ~signal_sync_d;
+    wire falling_edge = ~signal_sync &  signal_sync_d;
+
+    // Raw synchronized level is reported separately from period validity so
+    // static LOW and static HIGH remain distinguishable after edge timeout.
+    assign signal_level = signal_sync;
+
+    reg [31:0] period_counter;
+    reg [31:0] high_counter;
+    reg [31:0] no_signal_counter;
+
+    reg have_rise;
+    reg have_high;
+
+    always @(posedge clk or negedge reset_n) begin
+        if (!reset_n) begin
+            signal_meta   <= 1'b0;
+            signal_sync   <= 1'b0;
+            signal_sync_d <= 1'b0;
+        end else begin
+            signal_meta   <= signal_in;
+            signal_sync   <= signal_meta;
+            signal_sync_d <= signal_sync;
+        end
+    end
+
+    always @(posedge clk or negedge reset_n) begin
+        if (!reset_n) begin
+            edge_count         <= 32'd0;
+            period_ticks       <= 32'd0;
+            high_ticks         <= 32'd0;
+            measurement_valid  <= 1'b0;
+            signal_present     <= 1'b0;
+
+            period_counter     <= 32'd0;
+            high_counter       <= 32'd0;
+            no_signal_counter  <= 32'd0;
+
+            have_rise          <= 1'b0;
+            have_high          <= 1'b0;
+        end else begin
+
+            // ---------------------------------------------------------
+            // Rising edge:
+            //   - counts one pulse
+            //   - closes the previous complete period
+            //   - starts a new period/high counter
+            // ---------------------------------------------------------
+            if (rising_edge) begin
+                edge_count        <= edge_count + 1'b1;
+                signal_present    <= 1'b1;
+                no_signal_counter <= 32'd0;
+
+                if (have_rise) begin
+                    period_ticks <= period_counter;
+
+                    // high_ticks was latched by the previous falling edge,
+                    // therefore period_ticks + high_ticks belong to the
+                    // same just-finished PWM period.
+                    if (have_high)
+                        measurement_valid <= 1'b1;
+                end
+
+                period_counter <= 32'd1;
+                high_counter   <= 32'd1;
+                have_rise      <= 1'b1;
+                have_high      <= 1'b0;
+
+            end else begin
+                if (have_rise && period_counter != 32'hFFFF_FFFF)
+                    period_counter <= period_counter + 1'b1;
+
+                if (have_rise && signal_sync &&
+                    high_counter != 32'hFFFF_FFFF)
+                    high_counter <= high_counter + 1'b1;
+
+                // -----------------------------------------------------
+                // No-signal timeout.
+                // Keep edge_count, but clear frequency/duty information.
+                // -----------------------------------------------------
+                if (signal_present) begin
+                    if (no_signal_counter >= NO_SIGNAL_TICKS - 1) begin
+                        no_signal_counter <= 32'd0;
+                        signal_present    <= 1'b0;
+                        measurement_valid <= 1'b0;
+                        period_ticks      <= 32'd0;
+                        high_ticks        <= 32'd0;
+                        period_counter    <= 32'd0;
+                        high_counter      <= 32'd0;
+                        have_rise         <= 1'b0;
+                        have_high         <= 1'b0;
+                    end else begin
+                        no_signal_counter <= no_signal_counter + 1'b1;
+                    end
+                end
+            end
+
+            // ---------------------------------------------------------
+            // Falling edge closes the HIGH portion of the current PWM
+            // period. The value is paired with period_ticks on the next
+            // rising edge.
+            // ---------------------------------------------------------
+            if (falling_edge && have_rise) begin
+                high_ticks <= high_counter;
+                have_high  <= 1'b1;
+            end
+        end
+    end
+
+endmodule

+ 342 - 0
fpga/fpga_pwm_trace_project/rtl/pwm_trace_top.v

@@ -0,0 +1,342 @@
+`timescale 1ns/1ps
+
+// -----------------------------------------------------------------------------
+// pwm_trace_top - 8-input passive equipment Trace firmware
+//
+// J6 trace inputs (raw 3.3 V FPGA I/O):
+//   heater, fan, motor1, motor2, motor3, limit1, limit2, limit3
+//
+// The five actuator/control lines are measured as generic digital pulse/PWM
+// signals (count/period/high-time).  Their synchronized current level is also
+// reported, which makes static LOW and static HIGH observable.
+//
+// The three limit inputs are reported as raw synchronized levels, plus sticky
+// rising/falling flags since the previous report snapshot.  No active-high or
+// active-low semantic assumption is made here.
+//
+// UART stays on the existing J5 signal.  Protocol remains the 16-byte 0x82
+// packet format.  At 115200 baud one 16-packet snapshot needs about 22.2 ms on
+// the wire, therefore the production report interval is 50 ms (not the old
+// single-channel 10 ms interval).
+// -----------------------------------------------------------------------------
+module pwm_trace_top #(
+    parameter integer CLOCK_HZ  = 25_000_000,
+    parameter integer BAUD      = 115_200,
+    parameter integer REPORT_MS = 50
+)(
+    input  wire sys_clk,
+    input  wire reset_n,
+
+    input  wire trace_heater,
+    input  wire trace_fan,
+    input  wire trace_motor1,
+    input  wire trace_motor2,
+    input  wire trace_motor3,
+    input  wire trace_limit1,
+    input  wire trace_limit2,
+    input  wire trace_limit3,
+
+    output wire uart_tx,
+    output wire led_activity_n
+);
+
+    localparam [7:0] PKT_TRACE = 8'h82;
+
+    localparam [15:0] EVT_HEATER_COUNT  = 16'h5101;
+    localparam [15:0] EVT_HEATER_PERIOD = 16'h5102;
+    localparam [15:0] EVT_HEATER_HIGH   = 16'h5103;
+
+    localparam [15:0] EVT_FAN_COUNT     = 16'h5111;
+    localparam [15:0] EVT_FAN_PERIOD    = 16'h5112;
+    localparam [15:0] EVT_FAN_HIGH      = 16'h5113;
+
+    localparam [15:0] EVT_MOTOR1_COUNT  = 16'h5121;
+    localparam [15:0] EVT_MOTOR1_PERIOD = 16'h5122;
+    localparam [15:0] EVT_MOTOR1_HIGH   = 16'h5123;
+
+    localparam [15:0] EVT_MOTOR2_COUNT  = 16'h5131;
+    localparam [15:0] EVT_MOTOR2_PERIOD = 16'h5132;
+    localparam [15:0] EVT_MOTOR2_HIGH   = 16'h5133;
+
+    localparam [15:0] EVT_MOTOR3_COUNT  = 16'h5141;
+    localparam [15:0] EVT_MOTOR3_PERIOD = 16'h5142;
+    localparam [15:0] EVT_MOTOR3_HIGH   = 16'h5143;
+
+    // Status data layout:
+    //   [ 7: 0] raw synchronized levels:
+    //            bit0 heater, bit1 fan, bit2 motor1, bit3 motor2,
+    //            bit4 motor3, bit5 limit1, bit6 limit2, bit7 limit3
+    //   [12: 8] valid period/high flags for heater..motor3
+    //   [17:13] recent-edge/present flags for heater..motor3
+    //   [20:18] limit1..3 rising edge seen since previous snapshot
+    //   [23:21] limit1..3 falling edge seen since previous snapshot
+    //   [31:24] reserved = 0
+    localparam [15:0] EVT_DIGITAL_STATUS = 16'h5200;
+
+    localparam integer TICKS_PER_MS = CLOCK_HZ / 1000;
+    localparam integer REPORT_TICKS = TICKS_PER_MS * REPORT_MS;
+
+    // -------------------------------------------------------------------------
+    // Five generic PWM/pulse trace channels.
+    // -------------------------------------------------------------------------
+    wire [31:0] heater_count, heater_period, heater_high;
+    wire [31:0] fan_count,    fan_period,    fan_high;
+    wire [31:0] motor1_count, motor1_period, motor1_high;
+    wire [31:0] motor2_count, motor2_period, motor2_high;
+    wire [31:0] motor3_count, motor3_period, motor3_high;
+
+    wire heater_valid, fan_valid, motor1_valid, motor2_valid, motor3_valid;
+    wire heater_present, fan_present, motor1_present, motor2_present, motor3_present;
+    wire heater_level, fan_level, motor1_level, motor2_level, motor3_level;
+
+    pwm_trace_measure #(.CLK_HZ(CLOCK_HZ), .NO_SIGNAL_MS(100)) u_heater (
+        .clk(sys_clk), .reset_n(reset_n), .signal_in(trace_heater),
+        .edge_count(heater_count), .period_ticks(heater_period), .high_ticks(heater_high),
+        .measurement_valid(heater_valid), .signal_present(heater_present), .signal_level(heater_level)
+    );
+
+    pwm_trace_measure #(.CLK_HZ(CLOCK_HZ), .NO_SIGNAL_MS(100)) u_fan (
+        .clk(sys_clk), .reset_n(reset_n), .signal_in(trace_fan),
+        .edge_count(fan_count), .period_ticks(fan_period), .high_ticks(fan_high),
+        .measurement_valid(fan_valid), .signal_present(fan_present), .signal_level(fan_level)
+    );
+
+    pwm_trace_measure #(.CLK_HZ(CLOCK_HZ), .NO_SIGNAL_MS(100)) u_motor1 (
+        .clk(sys_clk), .reset_n(reset_n), .signal_in(trace_motor1),
+        .edge_count(motor1_count), .period_ticks(motor1_period), .high_ticks(motor1_high),
+        .measurement_valid(motor1_valid), .signal_present(motor1_present), .signal_level(motor1_level)
+    );
+
+    pwm_trace_measure #(.CLK_HZ(CLOCK_HZ), .NO_SIGNAL_MS(100)) u_motor2 (
+        .clk(sys_clk), .reset_n(reset_n), .signal_in(trace_motor2),
+        .edge_count(motor2_count), .period_ticks(motor2_period), .high_ticks(motor2_high),
+        .measurement_valid(motor2_valid), .signal_present(motor2_present), .signal_level(motor2_level)
+    );
+
+    pwm_trace_measure #(.CLK_HZ(CLOCK_HZ), .NO_SIGNAL_MS(100)) u_motor3 (
+        .clk(sys_clk), .reset_n(reset_n), .signal_in(trace_motor3),
+        .edge_count(motor3_count), .period_ticks(motor3_period), .high_ticks(motor3_high),
+        .measurement_valid(motor3_valid), .signal_present(motor3_present), .signal_level(motor3_level)
+    );
+
+    // -------------------------------------------------------------------------
+    // Limit switches: raw synchronized levels and sticky transition flags.
+    // -------------------------------------------------------------------------
+    wire [2:0] limit_level;
+    wire [2:0] limit_rise_seen;
+    wire [2:0] limit_fall_seen;
+    reg        limit_clear_seen;
+
+    limit_trace_capture u_limits (
+        .clk       (sys_clk),
+        .reset_n   (reset_n),
+        .limit_in  ({trace_limit3, trace_limit2, trace_limit1}),
+        .clear_seen(limit_clear_seen),
+        .level     (limit_level),
+        .rise_seen (limit_rise_seen),
+        .fall_seen (limit_fall_seen)
+    );
+
+    assign led_activity_n = ~(
+        heater_present | fan_present | motor1_present | motor2_present | motor3_present |
+        heater_level   | fan_level   | motor1_level   | motor2_level   | motor3_level   |
+        |limit_level
+    );
+
+    // -------------------------------------------------------------------------
+    // Millisecond timestamp.
+    // -------------------------------------------------------------------------
+    reg [31:0] ms_div;
+    reg [31:0] timestamp_ms;
+
+    always @(posedge sys_clk or negedge reset_n) begin
+        if (!reset_n) begin
+            ms_div       <= 32'd0;
+            timestamp_ms <= 32'd0;
+        end else if (ms_div == TICKS_PER_MS - 1) begin
+            ms_div       <= 32'd0;
+            timestamp_ms <= timestamp_ms + 1'b1;
+        end else begin
+            ms_div <= ms_div + 1'b1;
+        end
+    end
+
+    // -------------------------------------------------------------------------
+    // Periodic report trigger.
+    // -------------------------------------------------------------------------
+    reg [31:0] report_counter;
+    reg        report_tick;
+
+    always @(posedge sys_clk or negedge reset_n) begin
+        if (!reset_n) begin
+            report_counter <= 32'd0;
+            report_tick    <= 1'b0;
+        end else begin
+            report_tick <= 1'b0;
+            if (report_counter == REPORT_TICKS - 1) begin
+                report_counter <= 32'd0;
+                report_tick    <= 1'b1;
+            end else begin
+                report_counter <= report_counter + 1'b1;
+            end
+        end
+    end
+
+    // -------------------------------------------------------------------------
+    // Atomic snapshot registers.
+    // -------------------------------------------------------------------------
+    reg [31:0] snap_heater_count, snap_heater_period, snap_heater_high;
+    reg [31:0] snap_fan_count,    snap_fan_period,    snap_fan_high;
+    reg [31:0] snap_motor1_count, snap_motor1_period, snap_motor1_high;
+    reg [31:0] snap_motor2_count, snap_motor2_period, snap_motor2_high;
+    reg [31:0] snap_motor3_count, snap_motor3_period, snap_motor3_high;
+    reg [31:0] snap_status;
+    reg [31:0] snap_time;
+
+    // -------------------------------------------------------------------------
+    // UART packet burst: 15 measurement packets + 1 status packet.
+    // -------------------------------------------------------------------------
+    reg  [15:0] packet_seq;
+    reg  [4:0]  burst_index;
+    reg         burst_active;
+    reg         packet_start;
+    reg  [15:0] packet_event;
+    reg  [31:0] packet_data;
+    wire        packet_busy;
+
+    trace_packet_tx #(
+        .CLOCK_HZ(CLOCK_HZ),
+        .BAUD(BAUD)
+    ) u_packet_tx (
+        .clk        (sys_clk),
+        .reset_n    (reset_n),
+        .start      (packet_start),
+        .packet_type(PKT_TRACE),
+        .seq        (packet_seq),
+        .event_id   (packet_event),
+        .timestamp  (snap_time),
+        .data       (packet_data),
+        .uart_tx    (uart_tx),
+        .busy       (packet_busy)
+    );
+
+    always @(posedge sys_clk or negedge reset_n) begin
+        if (!reset_n) begin
+            snap_heater_count <= 32'd0; snap_heater_period <= 32'd0; snap_heater_high <= 32'd0;
+            snap_fan_count    <= 32'd0; snap_fan_period    <= 32'd0; snap_fan_high    <= 32'd0;
+            snap_motor1_count <= 32'd0; snap_motor1_period <= 32'd0; snap_motor1_high <= 32'd0;
+            snap_motor2_count <= 32'd0; snap_motor2_period <= 32'd0; snap_motor2_high <= 32'd0;
+            snap_motor3_count <= 32'd0; snap_motor3_period <= 32'd0; snap_motor3_high <= 32'd0;
+            snap_status       <= 32'd0;
+            snap_time         <= 32'd0;
+
+            packet_seq        <= 16'd0;
+            burst_index       <= 5'd0;
+            burst_active      <= 1'b0;
+            packet_start      <= 1'b0;
+            packet_event      <= 16'd0;
+            packet_data       <= 32'd0;
+            limit_clear_seen  <= 1'b0;
+        end else begin
+            packet_start     <= 1'b0;
+            limit_clear_seen <= 1'b0;
+
+            // Accept a new snapshot only when the entire previous UART burst
+            // has completed.  This prevents overlapping timestamps/data.
+            if (report_tick && !burst_active && !packet_busy) begin
+                snap_heater_count <= heater_count;
+                snap_fan_count    <= fan_count;
+                snap_motor1_count <= motor1_count;
+                snap_motor2_count <= motor2_count;
+                snap_motor3_count <= motor3_count;
+
+                if (heater_valid && heater_present) begin
+                    snap_heater_period <= heater_period;
+                    snap_heater_high   <= heater_high;
+                end else begin
+                    snap_heater_period <= 32'd0;
+                    snap_heater_high   <= 32'd0;
+                end
+
+                if (fan_valid && fan_present) begin
+                    snap_fan_period <= fan_period;
+                    snap_fan_high   <= fan_high;
+                end else begin
+                    snap_fan_period <= 32'd0;
+                    snap_fan_high   <= 32'd0;
+                end
+
+                if (motor1_valid && motor1_present) begin
+                    snap_motor1_period <= motor1_period;
+                    snap_motor1_high   <= motor1_high;
+                end else begin
+                    snap_motor1_period <= 32'd0;
+                    snap_motor1_high   <= 32'd0;
+                end
+
+                if (motor2_valid && motor2_present) begin
+                    snap_motor2_period <= motor2_period;
+                    snap_motor2_high   <= motor2_high;
+                end else begin
+                    snap_motor2_period <= 32'd0;
+                    snap_motor2_high   <= 32'd0;
+                end
+
+                if (motor3_valid && motor3_present) begin
+                    snap_motor3_period <= motor3_period;
+                    snap_motor3_high   <= motor3_high;
+                end else begin
+                    snap_motor3_period <= 32'd0;
+                    snap_motor3_high   <= 32'd0;
+                end
+
+                snap_status <= {
+                    8'd0,
+                    limit_fall_seen,
+                    limit_rise_seen,
+                    motor3_present, motor2_present, motor1_present, fan_present, heater_present,
+                    motor3_valid,   motor2_valid,   motor1_valid,   fan_valid,   heater_valid,
+                    limit_level[2], limit_level[1], limit_level[0],
+                    motor3_level, motor2_level, motor1_level, fan_level, heater_level
+                };
+
+                snap_time        <= timestamp_ms;
+                burst_index      <= 5'd0;
+                burst_active     <= 1'b1;
+                limit_clear_seen <= 1'b1;
+            end
+
+            if (burst_active && !packet_busy && !packet_start) begin
+                case (burst_index)
+                    5'd0:  begin packet_event <= EVT_HEATER_COUNT;  packet_data <= snap_heater_count;  end
+                    5'd1:  begin packet_event <= EVT_HEATER_PERIOD; packet_data <= snap_heater_period; end
+                    5'd2:  begin packet_event <= EVT_HEATER_HIGH;   packet_data <= snap_heater_high;   end
+                    5'd3:  begin packet_event <= EVT_FAN_COUNT;     packet_data <= snap_fan_count;     end
+                    5'd4:  begin packet_event <= EVT_FAN_PERIOD;    packet_data <= snap_fan_period;    end
+                    5'd5:  begin packet_event <= EVT_FAN_HIGH;      packet_data <= snap_fan_high;      end
+                    5'd6:  begin packet_event <= EVT_MOTOR1_COUNT;  packet_data <= snap_motor1_count;  end
+                    5'd7:  begin packet_event <= EVT_MOTOR1_PERIOD; packet_data <= snap_motor1_period; end
+                    5'd8:  begin packet_event <= EVT_MOTOR1_HIGH;   packet_data <= snap_motor1_high;   end
+                    5'd9:  begin packet_event <= EVT_MOTOR2_COUNT;  packet_data <= snap_motor2_count;  end
+                    5'd10: begin packet_event <= EVT_MOTOR2_PERIOD; packet_data <= snap_motor2_period; end
+                    5'd11: begin packet_event <= EVT_MOTOR2_HIGH;   packet_data <= snap_motor2_high;   end
+                    5'd12: begin packet_event <= EVT_MOTOR3_COUNT;  packet_data <= snap_motor3_count;  end
+                    5'd13: begin packet_event <= EVT_MOTOR3_PERIOD; packet_data <= snap_motor3_period; end
+                    5'd14: begin packet_event <= EVT_MOTOR3_HIGH;   packet_data <= snap_motor3_high;   end
+                    default: begin packet_event <= EVT_DIGITAL_STATUS; packet_data <= snap_status; end
+                endcase
+
+                packet_start <= 1'b1;
+                packet_seq   <= packet_seq + 1'b1;
+
+                if (burst_index == 5'd15) begin
+                    burst_active <= 1'b0;
+                    burst_index  <= 5'd0;
+                end else begin
+                    burst_index <= burst_index + 1'b1;
+                end
+            end
+        end
+    end
+
+endmodule

+ 195 - 0
fpga/fpga_pwm_trace_project/rtl/trace_packet_tx.v

@@ -0,0 +1,195 @@
+`timescale 1ns/1ps
+
+// -----------------------------------------------------------------------------
+// 16-byte UART Trace packet transmitter.
+//
+// This intentionally follows the packet format used by the user's previous
+// EF2L45 UART Trace project:
+//
+//   Byte0     A5
+//   Byte1     5A
+//   Byte2     packet type
+//   Byte3-4   sequence       (big-endian)
+//   Byte5-6   event_id       (big-endian)
+//   Byte7-10  timestamp      (big-endian)
+//   Byte11-14 data           (big-endian)
+//   Byte15    CRC-8
+//
+// CRC-8:
+//   polynomial = 0x07
+//   init       = 0x00
+//   covers bytes 2..14
+//
+// For this firmware packet_type is normally 0x82 (TRACE).
+// -----------------------------------------------------------------------------
+module trace_packet_tx #(
+    parameter integer CLOCK_HZ = 25_000_000,
+    parameter integer BAUD     = 115_200
+)(
+    input  wire        clk,
+    input  wire        reset_n,
+
+    input  wire        start,
+    input  wire [7:0]  packet_type,
+    input  wire [15:0] seq,
+    input  wire [15:0] event_id,
+    input  wire [31:0] timestamp,
+    input  wire [31:0] data,
+
+    output wire        uart_tx,
+    output reg         busy
+);
+
+    reg         uart_start;
+    reg  [7:0]  uart_data;
+    wire        uart_busy;
+
+    reg  [7:0]  type_latched;
+    reg  [15:0] seq_latched;
+    reg  [15:0] event_latched;
+    reg  [31:0] time_latched;
+    reg  [31:0] data_latched;
+
+    reg  [4:0]  byte_index;
+    reg  [7:0]  crc_reg;
+
+    localparam [1:0]
+        S_IDLE = 2'd0,
+        S_SEND = 2'd1,
+        S_WAIT = 2'd2;
+
+    reg [1:0] state;
+
+    uart_tx #(
+        .CLOCK_HZ(CLOCK_HZ),
+        .BAUD(BAUD)
+    ) u_uart_tx (
+        .clk    (clk),
+        .reset_n(reset_n),
+        .start  (uart_start),
+        .data   (uart_data),
+        .tx     (uart_tx),
+        .busy   (uart_busy)
+    );
+
+    function [7:0] crc8_next;
+        input [7:0] crc_in;
+        input [7:0] data_in;
+        integer i;
+        reg [7:0] c;
+        begin
+            c = crc_in ^ data_in;
+            for (i = 0; i < 8; i = i + 1) begin
+                if (c[7])
+                    c = (c << 1) ^ 8'h07;
+                else
+                    c = (c << 1);
+            end
+            crc8_next = c;
+        end
+    endfunction
+
+    function [7:0] packet_byte;
+        input [4:0] idx;
+        begin
+            case (idx)
+                5'd0:  packet_byte = 8'hA5;
+                5'd1:  packet_byte = 8'h5A;
+                5'd2:  packet_byte = type_latched;
+                5'd3:  packet_byte = seq_latched[15:8];
+                5'd4:  packet_byte = seq_latched[7:0];
+                5'd5:  packet_byte = event_latched[15:8];
+                5'd6:  packet_byte = event_latched[7:0];
+                5'd7:  packet_byte = time_latched[31:24];
+                5'd8:  packet_byte = time_latched[23:16];
+                5'd9:  packet_byte = time_latched[15:8];
+                5'd10: packet_byte = time_latched[7:0];
+                5'd11: packet_byte = data_latched[31:24];
+                5'd12: packet_byte = data_latched[23:16];
+                5'd13: packet_byte = data_latched[15:8];
+                5'd14: packet_byte = data_latched[7:0];
+                default: packet_byte = 8'h00;
+            endcase
+        end
+    endfunction
+
+    reg [7:0] current_byte;
+
+    always @(posedge clk or negedge reset_n) begin
+        if (!reset_n) begin
+            uart_start    <= 1'b0;
+            uart_data     <= 8'd0;
+            type_latched  <= 8'd0;
+            seq_latched   <= 16'd0;
+            event_latched <= 16'd0;
+            time_latched  <= 32'd0;
+            data_latched  <= 32'd0;
+            byte_index    <= 5'd0;
+            crc_reg       <= 8'd0;
+            current_byte  <= 8'd0;
+            state         <= S_IDLE;
+            busy          <= 1'b0;
+        end else begin
+            uart_start <= 1'b0;
+
+            case (state)
+                S_IDLE: begin
+                    busy <= 1'b0;
+
+                    if (start) begin
+                        type_latched  <= packet_type;
+                        seq_latched   <= seq;
+                        event_latched <= event_id;
+                        time_latched  <= timestamp;
+                        data_latched  <= data;
+                        byte_index    <= 5'd0;
+                        crc_reg       <= 8'd0;
+                        busy          <= 1'b1;
+                        state         <= S_SEND;
+                    end
+                end
+
+                S_SEND: begin
+                    busy <= 1'b1;
+
+                    if (!uart_busy) begin
+                        if (byte_index == 5'd15)
+                            current_byte = crc_reg;
+                        else
+                            current_byte = packet_byte(byte_index);
+
+                        uart_data  <= current_byte;
+                        uart_start <= 1'b1;
+
+                        if ((byte_index >= 5'd2) &&
+                            (byte_index <= 5'd14))
+                            crc_reg <= crc8_next(crc_reg, current_byte);
+
+                        state <= S_WAIT;
+                    end
+                end
+
+                S_WAIT: begin
+                    busy <= 1'b1;
+
+                    // Wait until the just-launched UART byte has fully left.
+                    if (!uart_busy && !uart_start) begin
+                        if (byte_index == 5'd15) begin
+                            busy  <= 1'b0;
+                            state <= S_IDLE;
+                        end else begin
+                            byte_index <= byte_index + 1'b1;
+                            state      <= S_SEND;
+                        end
+                    end
+                end
+
+                default: begin
+                    busy  <= 1'b0;
+                    state <= S_IDLE;
+                end
+            endcase
+        end
+    end
+
+endmodule

+ 108 - 0
fpga/fpga_pwm_trace_project/rtl/uart_tx.v

@@ -0,0 +1,108 @@
+`timescale 1ns/1ps
+
+// -----------------------------------------------------------------------------
+// Simple 8-N-1 UART transmitter.
+// Default: 25 MHz input clock, 115200 baud.
+// -----------------------------------------------------------------------------
+module uart_tx #(
+    parameter integer CLOCK_HZ = 25_000_000,
+    parameter integer BAUD     = 115_200
+)(
+    input  wire       clk,
+    input  wire       reset_n,
+    input  wire       start,
+    input  wire [7:0] data,
+
+    output reg        tx,
+    output reg        busy
+);
+
+    localparam integer CLKS_PER_BIT =
+        (CLOCK_HZ + (BAUD / 2)) / BAUD;
+
+    localparam [2:0]
+        S_IDLE  = 3'd0,
+        S_START = 3'd1,
+        S_DATA  = 3'd2,
+        S_STOP  = 3'd3;
+
+    reg [2:0]  state;
+    reg [31:0] clk_count;
+    reg [2:0]  bit_index;
+    reg [7:0]  shift_reg;
+
+    always @(posedge clk or negedge reset_n) begin
+        if (!reset_n) begin
+            tx        <= 1'b1;
+            busy      <= 1'b0;
+            state     <= S_IDLE;
+            clk_count <= 32'd0;
+            bit_index <= 3'd0;
+            shift_reg <= 8'd0;
+        end else begin
+            case (state)
+                S_IDLE: begin
+                    tx        <= 1'b1;
+                    busy      <= 1'b0;
+                    clk_count <= 32'd0;
+
+                    if (start) begin
+                        shift_reg <= data;
+                        busy      <= 1'b1;
+                        state     <= S_START;
+                    end
+                end
+
+                S_START: begin
+                    tx   <= 1'b0;
+                    busy <= 1'b1;
+
+                    if (clk_count == CLKS_PER_BIT - 1) begin
+                        clk_count <= 32'd0;
+                        bit_index <= 3'd0;
+                        state     <= S_DATA;
+                    end else begin
+                        clk_count <= clk_count + 1'b1;
+                    end
+                end
+
+                S_DATA: begin
+                    tx   <= shift_reg[bit_index];
+                    busy <= 1'b1;
+
+                    if (clk_count == CLKS_PER_BIT - 1) begin
+                        clk_count <= 32'd0;
+
+                        if (bit_index == 3'd7) begin
+                            state <= S_STOP;
+                        end else begin
+                            bit_index <= bit_index + 1'b1;
+                        end
+                    end else begin
+                        clk_count <= clk_count + 1'b1;
+                    end
+                end
+
+                S_STOP: begin
+                    tx   <= 1'b1;
+                    busy <= 1'b1;
+
+                    if (clk_count == CLKS_PER_BIT - 1) begin
+                        clk_count <= 32'd0;
+                        busy      <= 1'b0;
+                        state     <= S_IDLE;
+                    end else begin
+                        clk_count <= clk_count + 1'b1;
+                    end
+                end
+
+                default: begin
+                    tx    <= 1'b1;
+                    busy  <= 1'b0;
+                    state <= S_IDLE;
+                end
+            endcase
+        end
+    end
+
+endmodule

+ 41 - 0
fpga/fpga_pwm_trace_project/scripts/build.sh

@@ -0,0 +1,41 @@
+#!/bin/bash
+set -euo pipefail
+
+PROJECT="$(cd "$(dirname "$0")/.." && pwd)"
+TD_DEFAULT="$(cd "$PROJECT/../.." && pwd)/TD_4.6.7_Linux/bin/td"
+TD="${TD_BIN:-$TD_DEFAULT}"
+LOG="$PROJECT/build/td/td_build.log"
+BIT="$PROJECT/build/td/fpga_pwm_trace.bit"
+
+mkdir -p "$PROJECT/build/td"
+cd "$PROJECT"
+rm -f "$BIT"
+
+if [ ! -x "$TD" ]; then
+    echo "ERROR: TD executable not found: $TD" >&2
+    echo "Set TD_BIN=/path/to/td if the bundle is installed elsewhere." >&2
+    exit 1
+fi
+
+echo "=============================================="
+echo "TD FPGA BUILD"
+echo "TD: $TD"
+echo "=============================================="
+
+"$TD" < scripts/td_build.tcl | tee "$LOG"
+
+if [ ! -s "$BIT" ]; then
+    echo "ERROR: bitstream was not generated: $BIT" >&2
+    exit 1
+fi
+
+if ! grep -q 'BIT-1004 : Generate bits file build/td/fpga_pwm_trace.bit' "$LOG"; then
+    echo "ERROR: TD log does not contain the expected BIT-1004 success marker" >&2
+    exit 1
+fi
+
+echo
+echo "=============================================="
+echo "FPGA BUILD PASS"
+echo "=============================================="
+ls -lh "$BIT"

+ 21 - 0
fpga/fpga_pwm_trace_project/scripts/program.sh

@@ -0,0 +1,21 @@
+#!/bin/bash
+set -euo pipefail
+
+PROJECT="$(cd "$(dirname "$0")/.." && pwd)"
+TD_DEFAULT="$(cd "$PROJECT/../.." && pwd)/TD_4.6.7_Linux/bin/td"
+TD="${TD_BIN:-$TD_DEFAULT}"
+BIT="$PROJECT/build/td/fpga_pwm_trace.bit"
+
+if [ ! -x "$TD" ]; then
+    echo "ERROR: TD executable not found: $TD" >&2
+    exit 1
+fi
+if [ ! -s "$BIT" ]; then
+    echo "ERROR: bitstream not found: $BIT" >&2
+    echo "Run ./scripts/build.sh first." >&2
+    exit 1
+fi
+
+# These are the exact low-level parameters previously verified on the real
+# EF2L45 board: jtag_burst + cable 0 + SVF speed 4.
+printf 'bit_to_vec -chip EF2L45B -m jtag_burst -bit %s\nprogram -cable 0 -mode svf -spd 4 -p\nexit\n' "$BIT" | "$TD"

+ 43 - 0
fpga/fpga_pwm_trace_project/scripts/run_test.sh

@@ -0,0 +1,43 @@
+#!/bin/bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+mkdir -p build
+
+if ! command -v iverilog >/dev/null 2>&1 || ! command -v vvp >/dev/null 2>&1; then
+    echo "ERROR: iverilog/vvp not found. Install Icarus Verilog before running simulation tests." >&2
+    exit 127
+fi
+
+echo "=============================================="
+echo "1/3 PWM measurement core simulation"
+echo "=============================================="
+iverilog -g2012 -s pwm_trace_measure_tb -o build/pwm_trace_measure_tb.vvp \
+  rtl/pwm_trace_measure.v sim/pwm_trace_measure_tb.v
+vvp build/pwm_trace_measure_tb.vvp
+
+echo
+echo "=============================================="
+echo "2/3 Limit trace capture simulation"
+echo "=============================================="
+iverilog -g2012 -s limit_trace_capture_tb -o build/limit_trace_capture_tb.vvp \
+  rtl/limit_trace_capture.v sim/limit_trace_capture_tb.v
+vvp build/limit_trace_capture_tb.vvp
+
+echo
+echo "=============================================="
+echo "3/3 8-channel UART Trace integration simulation"
+echo "=============================================="
+iverilog -g2012 -s pwm_trace_uart_tb -o build/pwm_trace_uart_tb.vvp \
+  rtl/pwm_trace_measure.v \
+  rtl/limit_trace_capture.v \
+  rtl/uart_tx.v \
+  rtl/trace_packet_tx.v \
+  rtl/pwm_trace_top.v \
+  sim/pwm_trace_uart_tb.v
+vvp build/pwm_trace_uart_tb.vvp
+
+echo
+echo "=============================================="
+echo "ALL PROJECT TESTBENCHES PASSED"
+echo "=============================================="

+ 20 - 0
fpga/fpga_pwm_trace_project/scripts/td_build.tcl

@@ -0,0 +1,20 @@
+open_project fpga_pwm_trace.al
+elaborate -top pwm_trace_top
+read_adc constraints/pwm_trace.adc
+read_sdc constraints/pwm_trace.sdc
+
+optimize_rtl
+report_area -file build/td/fpga_pwm_trace_rtl.area
+export_db build/td/fpga_pwm_trace_rtl.db
+
+optimize_gate -packarea build/td/fpga_pwm_trace_gate.area
+report_area -file build/td/fpga_pwm_trace_gate.area
+legalize_phy_inst
+export_db build/td/fpga_pwm_trace_gate.db
+
+place
+route
+
+bitgen -bit build/td/fpga_pwm_trace.bit
+
+exit

+ 80 - 0
fpga/fpga_pwm_trace_project/sim/limit_trace_capture_tb.v

@@ -0,0 +1,80 @@
+`timescale 1ns/1ps
+
+module limit_trace_capture_tb;
+    reg clk = 1'b0;
+    reg reset_n = 1'b0;
+    reg [2:0] limit_in = 3'b000;
+    reg clear_seen = 1'b0;
+
+    wire [2:0] level;
+    wire [2:0] rise_seen;
+    wire [2:0] fall_seen;
+
+    integer errors = 0;
+
+    always #20 clk = ~clk;
+
+    limit_trace_capture dut (
+        .clk(clk),
+        .reset_n(reset_n),
+        .limit_in(limit_in),
+        .clear_seen(clear_seen),
+        .level(level),
+        .rise_seen(rise_seen),
+        .fall_seen(fall_seen)
+    );
+
+    task wait_sync;
+        begin
+            repeat (5) @(posedge clk);
+        end
+    endtask
+
+    initial begin
+        $display("");
+        $display("==============================================");
+        $display(" LIMIT TRACE CAPTURE TEST");
+        $display("==============================================");
+
+        repeat (5) @(posedge clk);
+        reset_n = 1'b1;
+        wait_sync();
+
+        limit_in[0] = 1'b1;
+        wait_sync();
+        if (level[0] !== 1'b1 || rise_seen[0] !== 1'b1) begin
+            $display("FAIL limit1 rising capture");
+            errors = errors + 1;
+        end
+
+        limit_in[1] = 1'b1;
+        wait_sync();
+        limit_in[0] = 1'b0;
+        wait_sync();
+        if (level !== 3'b010) begin
+            $display("FAIL level expected=010 actual=%b", level);
+            errors = errors + 1;
+        end
+        if (rise_seen[1:0] !== 2'b11 || fall_seen[0] !== 1'b1) begin
+            $display("FAIL sticky edge capture rise=%b fall=%b", rise_seen, fall_seen);
+            errors = errors + 1;
+        end
+
+        clear_seen = 1'b1;
+        @(posedge clk);
+        clear_seen = 1'b0;
+        repeat (2) @(posedge clk);
+        if (rise_seen !== 3'b000 || fall_seen !== 3'b000) begin
+            $display("FAIL sticky clear rise=%b fall=%b", rise_seen, fall_seen);
+            errors = errors + 1;
+        end
+
+        if (errors == 0) begin
+            $display("ALL LIMIT TRACE TESTS PASSED");
+            $finish;
+        end else begin
+            $display("LIMIT TRACE TEST FAILED: %0d error(s)", errors);
+            $fatal(1);
+        end
+    end
+endmodule

+ 156 - 0
fpga/fpga_pwm_trace_project/sim/pwm_trace_measure_tb.v

@@ -0,0 +1,156 @@
+`timescale 1ns/1ps
+
+module pwm_trace_measure_tb;
+
+    localparam integer CLK_HZ = 25_000_000;
+
+    reg clk = 1'b0;
+    reg reset_n = 1'b0;
+    reg signal_in = 1'b0;
+
+    wire [31:0] edge_count;
+    wire [31:0] period_ticks;
+    wire [31:0] high_ticks;
+    wire        measurement_valid;
+    wire        signal_present;
+
+    integer errors = 0;
+
+    // 25 MHz => 40 ns period.
+    always #20 clk = ~clk;
+
+    pwm_trace_measure #(
+        .CLK_HZ(CLK_HZ),
+        // Short timeout only for simulation speed.
+        .NO_SIGNAL_MS(2)
+    ) dut (
+        .clk(clk),
+        .reset_n(reset_n),
+        .signal_in(signal_in),
+        .edge_count(edge_count),
+        .period_ticks(period_ticks),
+        .high_ticks(high_ticks),
+        .measurement_valid(measurement_valid),
+        .signal_present(signal_present)
+    );
+
+    task check_equal;
+        input [8*40-1:0] name;
+        input [31:0] actual;
+        input [31:0] expected;
+        begin
+            if (actual !== expected) begin
+                $display("FAIL %-24s expected=%0d actual=%0d",
+                         name, expected, actual);
+                errors = errors + 1;
+            end else begin
+                $display("PASS %-24s %0d", name, actual);
+            end
+        end
+    endtask
+
+    // Drive a PWM whose edges occur on clk negative edges.
+    // This keeps the asynchronous-input test deterministic while still
+    // exercising the two-flop synchronizer in the DUT.
+    task drive_pwm_ticks;
+        input integer period;
+        input integer high;
+        input integer pulses;
+        integer i;
+        begin
+            @(negedge clk);
+
+            for (i = 0; i < pulses; i = i + 1) begin
+                signal_in = 1'b1;
+                repeat (high) @(negedge clk);
+
+                signal_in = 1'b0;
+                repeat (period - high) @(negedge clk);
+            end
+        end
+    endtask
+
+    initial begin
+        $display("");
+        $display("==============================================");
+        $display(" PWM TRACE MEASUREMENT CORE TEST");
+        $display("==============================================");
+
+        repeat (10) @(posedge clk);
+        reset_n = 1'b1;
+        repeat (5) @(posedge clk);
+
+        // ------------------------------------------------------------
+        // Case A:
+        // 25 MHz / 1000 ticks = 25 kHz
+        // 250 / 1000 = 25%
+        // ------------------------------------------------------------
+        $display("");
+        $display("[A] 25 kHz, 25%% duty, 8 pulses");
+        drive_pwm_ticks(1000, 250, 8);
+        repeat (6) @(posedge clk);
+
+        check_equal("edge_count",   edge_count,   8);
+        check_equal("period_ticks", period_ticks, 1000);
+        check_equal("high_ticks",   high_ticks,   250);
+
+        if (!measurement_valid || !signal_present) begin
+            $display("FAIL valid/present expected 1/1 actual %0d/%0d",
+                     measurement_valid, signal_present);
+            errors = errors + 1;
+        end else begin
+            $display("PASS measurement_valid/signal_present");
+        end
+
+        // ------------------------------------------------------------
+        // Case B:
+        // 25 MHz / 2500 ticks = 10 kHz
+        // 1500 / 2500 = 60%
+        // ------------------------------------------------------------
+        $display("");
+        $display("[B] 10 kHz, 60%% duty, 6 more pulses");
+        drive_pwm_ticks(2500, 1500, 6);
+        repeat (6) @(posedge clk);
+
+        check_equal("edge_count cumulative", edge_count,   14);
+        check_equal("period_ticks",           period_ticks, 2500);
+        check_equal("high_ticks",             high_ticks,   1500);
+
+        // ------------------------------------------------------------
+        // Case C: signal disappears.
+        // Simulation timeout is configured to 2 ms.
+        // Count must remain, frequency/duty data must become invalid.
+        // ------------------------------------------------------------
+        $display("");
+        $display("[C] no-signal timeout");
+        signal_in = 1'b0;
+        repeat (52000) @(posedge clk); // > 2 ms at 25 MHz
+
+        check_equal("edge_count retained", edge_count, 14);
+        check_equal("period cleared",      period_ticks, 0);
+        check_equal("high cleared",        high_ticks, 0);
+
+        if (measurement_valid !== 1'b0 ||
+            signal_present !== 1'b0) begin
+            $display("FAIL timeout flags expected 0/0 actual %0d/%0d",
+                     measurement_valid, signal_present);
+            errors = errors + 1;
+        end else begin
+            $display("PASS timeout flags");
+        end
+
+        $display("");
+        $display("==============================================");
+
+        if (errors == 0) begin
+            $display("ALL PWM TRACE CORE TESTS PASSED");
+            $display("==============================================");
+            $finish;
+        end else begin
+            $display("PWM TRACE CORE TEST FAILED: %0d error(s)", errors);
+            $display("==============================================");
+            $fatal(1);
+        end
+    end
+
+endmodule

+ 299 - 0
fpga/fpga_pwm_trace_project/sim/pwm_trace_uart_tb.v

@@ -0,0 +1,299 @@
+`timescale 1ns/1ps
+
+module pwm_trace_uart_tb;
+
+    localparam integer CLOCK_HZ  = 25_000_000;
+    localparam integer TEST_BAUD = 1_000_000;
+    localparam integer BIT_NS    = 1000;
+
+    reg sys_clk = 1'b0;
+    reg reset_n = 1'b0;
+
+    reg trace_heater = 1'b0;
+    reg trace_fan    = 1'b0;
+    reg trace_motor1 = 1'b0;
+    reg trace_motor2 = 1'b0;
+    reg trace_motor3 = 1'b0;
+    reg trace_limit1 = 1'b0;
+    reg trace_limit2 = 1'b0;
+    reg trace_limit3 = 1'b0;
+
+    wire uart_tx;
+    wire led_activity_n;
+
+    integer errors = 0;
+    integer i;
+
+    reg [7:0] packet [0:15];
+    reg [15:0] event_id;
+    reg [31:0] data_value;
+    reg [31:0] packet_time;
+    reg [31:0] first_timestamp;
+
+    reg [31:0] heater_count, heater_period, heater_high;
+    reg [31:0] fan_count, fan_period, fan_high;
+    reg [31:0] motor1_count, motor1_period, motor1_high;
+    reg [31:0] motor2_count, motor2_period, motor2_high;
+    reg [31:0] motor3_count, motor3_period, motor3_high;
+    reg [31:0] status_data;
+
+    always #20 sys_clk = ~sys_clk;
+
+    pwm_trace_top #(
+        .CLOCK_HZ(CLOCK_HZ),
+        .BAUD(TEST_BAUD),
+        .REPORT_MS(20)
+    ) dut (
+        .sys_clk(sys_clk),
+        .reset_n(reset_n),
+        .trace_heater(trace_heater),
+        .trace_fan(trace_fan),
+        .trace_motor1(trace_motor1),
+        .trace_motor2(trace_motor2),
+        .trace_motor3(trace_motor3),
+        .trace_limit1(trace_limit1),
+        .trace_limit2(trace_limit2),
+        .trace_limit3(trace_limit3),
+        .uart_tx(uart_tx),
+        .led_activity_n(led_activity_n)
+    );
+
+    // Distinct waveforms verify that each event ID is tied to the intended
+    // channel rather than accidentally reusing another channel's measurement.
+    initial forever begin
+        @(negedge sys_clk); trace_heater = 1'b1;
+        repeat (250) @(negedge sys_clk); trace_heater = 1'b0;
+        repeat (749) @(negedge sys_clk);
+    end
+
+    initial forever begin
+        @(negedge sys_clk); trace_fan = 1'b1;
+        repeat (500) @(negedge sys_clk); trace_fan = 1'b0;
+        repeat (749) @(negedge sys_clk);
+    end
+
+    initial forever begin
+        @(negedge sys_clk); trace_motor1 = 1'b1;
+        repeat (1250) @(negedge sys_clk); trace_motor1 = 1'b0;
+        repeat (1249) @(negedge sys_clk);
+    end
+
+    initial forever begin
+        @(negedge sys_clk); trace_motor2 = 1'b1;
+        repeat (1000) @(negedge sys_clk); trace_motor2 = 1'b0;
+        repeat (3999) @(negedge sys_clk);
+    end
+
+    initial forever begin
+        @(negedge sys_clk); trace_motor3 = 1'b1;
+        repeat (7500) @(negedge sys_clk); trace_motor3 = 1'b0;
+        repeat (2499) @(negedge sys_clk);
+    end
+
+    // Limit transitions before the first 20 ms report:
+    // current raw state at snapshot: L3=1, L2=1, L1=0 -> 3'b110
+    // rising seen: all three; falling seen: L1 only.
+    initial begin
+        wait(reset_n == 1'b1);
+        #2000000; trace_limit1 = 1'b1;
+        #2000000; trace_limit2 = 1'b1;
+        #2000000; trace_limit1 = 1'b0;
+        #2000000; trace_limit3 = 1'b1;
+    end
+
+    function [7:0] crc8_next;
+        input [7:0] crc_in;
+        input [7:0] data_in;
+        integer k;
+        reg [7:0] c;
+        begin
+            c = crc_in ^ data_in;
+            for (k = 0; k < 8; k = k + 1) begin
+                if (c[7])
+                    c = (c << 1) ^ 8'h07;
+                else
+                    c = (c << 1);
+            end
+            crc8_next = c;
+        end
+    endfunction
+
+    task uart_recv_byte;
+        output [7:0] value;
+        integer b;
+        begin
+            @(negedge uart_tx);
+            #(BIT_NS + BIT_NS/2);
+            for (b = 0; b < 8; b = b + 1) begin
+                value[b] = uart_tx;
+                #BIT_NS;
+            end
+            if (uart_tx !== 1'b1) begin
+                $display("FAIL UART stop bit");
+                errors = errors + 1;
+            end
+            #(BIT_NS/2);
+        end
+    endtask
+
+    task recv_packet;
+        integer n;
+        reg [7:0] crc;
+        begin
+            packet[0] = 8'h00;
+            packet[1] = 8'h00;
+
+            while (!((packet[0] == 8'hA5) && (packet[1] == 8'h5A))) begin
+                packet[0] = packet[1];
+                uart_recv_byte(packet[1]);
+            end
+
+            for (n = 2; n < 16; n = n + 1)
+                uart_recv_byte(packet[n]);
+
+            crc = 8'h00;
+            for (n = 2; n <= 14; n = n + 1)
+                crc = crc8_next(crc, packet[n]);
+
+            if (crc !== packet[15]) begin
+                $display("FAIL CRC expected=%02x actual=%02x", crc, packet[15]);
+                errors = errors + 1;
+            end
+
+            if (packet[2] !== 8'h82) begin
+                $display("FAIL packet type expected=82 actual=%02x", packet[2]);
+                errors = errors + 1;
+            end
+
+            event_id = {packet[5], packet[6]};
+            packet_time = {packet[7], packet[8], packet[9], packet[10]};
+            data_value = {packet[11], packet[12], packet[13], packet[14]};
+        end
+    endtask
+
+    task check_u32;
+        input [8*24-1:0] label_text;
+        input [31:0] actual;
+        input [31:0] expected;
+        begin
+            if (actual !== expected) begin
+                $display("FAIL %0s expected=%0d actual=%0d", label_text, expected, actual);
+                errors = errors + 1;
+            end else begin
+                $display("PASS %0s = %0d", label_text, actual);
+            end
+        end
+    endtask
+
+    initial begin
+        $display("");
+        $display("==============================================");
+        $display(" 8-CHANNEL TRACE UART INTEGRATION TEST");
+        $display("==============================================");
+
+        repeat (10) @(posedge sys_clk);
+        reset_n = 1'b1;
+
+        heater_count = 0; heater_period = 0; heater_high = 0;
+        fan_count = 0; fan_period = 0; fan_high = 0;
+        motor1_count = 0; motor1_period = 0; motor1_high = 0;
+        motor2_count = 0; motor2_period = 0; motor2_high = 0;
+        motor3_count = 0; motor3_period = 0; motor3_high = 0;
+        status_data = 0;
+        first_timestamp = 32'hFFFF_FFFF;
+
+        for (i = 0; i < 16; i = i + 1) begin
+            recv_packet();
+            $display("TRACE event=0x%04x timestamp=%0d data=%0d", event_id, packet_time, data_value);
+
+            if (i == 0)
+                first_timestamp = packet_time;
+            else if (packet_time !== first_timestamp) begin
+                $display("FAIL snapshot timestamps differ");
+                errors = errors + 1;
+            end
+
+            case (event_id)
+                16'h5101: heater_count  = data_value;
+                16'h5102: heater_period = data_value;
+                16'h5103: heater_high   = data_value;
+                16'h5111: fan_count     = data_value;
+                16'h5112: fan_period    = data_value;
+                16'h5113: fan_high      = data_value;
+                16'h5121: motor1_count  = data_value;
+                16'h5122: motor1_period = data_value;
+                16'h5123: motor1_high   = data_value;
+                16'h5131: motor2_count  = data_value;
+                16'h5132: motor2_period = data_value;
+                16'h5133: motor2_high   = data_value;
+                16'h5141: motor3_count  = data_value;
+                16'h5142: motor3_period = data_value;
+                16'h5143: motor3_high   = data_value;
+                16'h5200: status_data   = data_value;
+                default: begin
+                    $display("FAIL unexpected event ID 0x%04x", event_id);
+                    errors = errors + 1;
+                end
+            endcase
+        end
+
+        if (heater_count == 0 || fan_count == 0 || motor1_count == 0 || motor2_count == 0 || motor3_count == 0) begin
+            $display("FAIL one or more pulse counts are zero");
+            errors = errors + 1;
+        end else begin
+            $display("PASS all five pulse counts > 0");
+        end
+
+        check_u32("heater_period", heater_period, 32'd1000);
+        check_u32("heater_high",   heater_high,   32'd250);
+        check_u32("fan_period",    fan_period,    32'd1250);
+        check_u32("fan_high",      fan_high,      32'd500);
+        check_u32("motor1_period", motor1_period, 32'd2500);
+        check_u32("motor1_high",   motor1_high,   32'd1250);
+        check_u32("motor2_period", motor2_period, 32'd5000);
+        check_u32("motor2_high",   motor2_high,   32'd1000);
+        check_u32("motor3_period", motor3_period, 32'd10000);
+        check_u32("motor3_high",   motor3_high,   32'd7500);
+
+        // Status bit layout check. PWM instantaneous level bits [4:0] are not
+        // checked because the report can occur at any phase of each waveform.
+        if (status_data[7:5] !== 3'b110) begin
+            $display("FAIL limit levels expected=110 actual=%b", status_data[7:5]);
+            errors = errors + 1;
+        end else begin
+            $display("PASS limit raw levels = 110");
+        end
+
+        if (status_data[12:8] !== 5'b11111) begin
+            $display("FAIL valid flags expected=11111 actual=%b", status_data[12:8]);
+            errors = errors + 1;
+        end
+
+        if (status_data[17:13] !== 5'b11111) begin
+            $display("FAIL present flags expected=11111 actual=%b", status_data[17:13]);
+            errors = errors + 1;
+        end
+
+        if (status_data[20:18] !== 3'b111) begin
+            $display("FAIL limit rise-seen expected=111 actual=%b", status_data[20:18]);
+            errors = errors + 1;
+        end
+
+        if (status_data[23:21] !== 3'b001) begin
+            $display("FAIL limit fall-seen expected=001 actual=%b", status_data[23:21]);
+            errors = errors + 1;
+        end
+
+        $display("");
+        $display("==============================================");
+        if (errors == 0) begin
+            $display("ALL 8-CHANNEL TRACE UART TESTS PASSED");
+            $display("==============================================");
+            $finish;
+        end else begin
+            $display("8-CHANNEL TRACE UART TEST FAILED: %0d error(s)", errors);
+            $display("==============================================");
+            $fatal(1);
+        end
+    end
+endmodule