diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index d445166d..a96ffdf2 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -1,4 +1,6 @@
---
+
+[简体中文](bug_report_ZH.md)
name: Bug report
about: Report a reproducible problem with a board example
title: "[Bug] "
diff --git a/.github/ISSUE_TEMPLATE/bug_report_ZH.md b/.github/ISSUE_TEMPLATE/bug_report_ZH.md
new file mode 100644
index 00000000..4d02c00e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report_ZH.md
@@ -0,0 +1,41 @@
+---
+name: 缺陷报告
+about: 报告可复现的开发板示例问题
+title: "[Bug] "
+labels: bug
+assignees: ""
+---
+
+[English](bug_report.md)
+
+## 摘要
+
+请简要说明问题。
+
+## 示例
+
+- 示例路径:
+- 框架和版本:
+- 板卡版本:
+
+## 复现步骤
+
+1.
+2.
+3.
+
+## 预期行为
+
+应发生什么?
+
+## 实际行为
+
+实际发生了什么?
+
+## 日志或截图
+
+请粘贴相关串口日志、构建日志摘录或截图。
+
+## 其他说明
+
+请补充硬件连接、模块或配置改动。
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index c7ebaf4d..71d158d0 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,12 +1,24 @@
## Summary
+[简体中文](pull_request_template_ZH.md)
+
- TODO
## Validation
- [ ] GitHub Actions passed
+- [ ] Affected examples and framework(s) are listed
+- [ ] The Actions matrix/selected targets are noted
+- [ ] Firmware artifact impact is stated (including no impact)
+- [ ] Managed component changes are described, if any
- [ ] Hardware behavior checked, if applicable
+## Scope Notes
+
+- Affected example paths and frameworks:
+- Hardware or pin considerations:
+- Firmware artifact impact:
+
## Notes
- TODO
diff --git a/.github/pull_request_template_ZH.md b/.github/pull_request_template_ZH.md
new file mode 100644
index 00000000..910179cd
--- /dev/null
+++ b/.github/pull_request_template_ZH.md
@@ -0,0 +1,20 @@
+## 概要
+
+[English](pull_request_template.md)
+
+- TODO
+
+## 验证
+
+- [ ] GitHub Actions 已通过
+- [ ] 已列出受影响的示例和框架
+- [ ] 已说明 Actions 矩阵/所选目标
+- [ ] 已说明固件工件影响(包括无影响)
+- [ ] 已说明托管组件变更(如有)
+- [ ] 已检查硬件行为(如适用)
+
+## 范围说明
+
+- 受影响的示例路径和框架:
+- 硬件或引脚注意事项:
+- 固件工件影响:
diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml
index 72e58530..ae38cf4d 100644
--- a/.github/workflows/examples.yml
+++ b/.github/workflows/examples.yml
@@ -2,26 +2,9 @@ name: Build Examples
on:
push:
- branches:
- - main
- - master
- tags:
- - "v*"
- paths:
- - ".github/workflows/examples.yml"
- - "scripts/discover_examples.py"
- - "releases/**"
- - "config/**"
- - "examples/esp-idf/**"
- - "examples/arduino/**"
+ branches: [main, master]
+ tags: ["v*"]
pull_request:
- paths:
- - ".github/workflows/examples.yml"
- - "scripts/discover_examples.py"
- - "releases/**"
- - "config/**"
- - "examples/esp-idf/**"
- - "examples/arduino/**"
workflow_dispatch:
inputs:
target:
@@ -29,18 +12,82 @@ on:
required: false
default: "all"
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
+
permissions:
contents: read
env:
- IDF_VERSIONS: "v5.5.4,v6.0.2"
+ IDF_VERSIONS: "v5.5.5,v6.0.2"
IDF_TARGET: "esp32s3"
- ARDUINO_CORE_VERSION: "3.3.10"
+ ARDUINO_CORE_VERSION: "3.3.11"
ARDUINO_FQBN: "esp32:esp32:esp32s3:USBMode=hwcdc,CDCOnBoot=cdc,PSRAM=opi,FlashSize=16M,PartitionScheme=app3M_fat9M_16MB"
PRODUCT_NAME: "ESP32-S3-Touch-AMOLED-2.06"
jobs:
+ quality:
+ name: Quality and documentation scope
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Check repository documentation conventions
+ run: python3 scripts/check_docs.py . --config config/markdown-audit.json
+ - name: Run routing, discovery, and documentation checker tests
+ run: python3 -m unittest discover -s tests -v
+
+ scope:
+ name: Classify changed scope
+ runs-on: ubuntu-latest
+ outputs:
+ esp_idf_mode: ${{ steps.classify.outputs.esp_idf_mode }}
+ arduino_mode: ${{ steps.classify.outputs.arduino_mode }}
+ esp_idf_paths: ${{ steps.classify.outputs.esp_idf_paths }}
+ arduino_paths: ${{ steps.classify.outputs.arduino_paths }}
+ firmware_paths: ${{ steps.classify.outputs.firmware_paths }}
+ unknown_paths: ${{ steps.classify.outputs.unknown_paths }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 2
+ - id: classify
+ shell: bash
+ run: |
+ if [[ "${{ github.event_name }}" == "workflow_dispatch" || "${{ github.ref }}" == refs/tags/* ]]; then
+ {
+ echo 'esp_idf_mode=all'
+ echo 'arduino_mode=all'
+ echo 'esp_idf_paths=[]'
+ echo 'arduino_paths=[]'
+ echo 'firmware_paths=[]'
+ echo 'unknown_paths=[]'
+ } >> "$GITHUB_OUTPUT"
+ else
+ if [[ "${{ github.event_name }}" == "pull_request" ]]; then
+ base="${{ github.event.pull_request.base.sha }}"
+ else
+ base="${{ github.event.before }}"
+ fi
+ git fetch --no-tags --depth=1 origin "$base"
+ git diff --name-status -z "$base" "${{ github.sha }}" > changed-files.z
+ python3 scripts/ci_scope.py --name-status-file changed-files.z --github-output "$GITHUB_OUTPUT"
+ fi
+ esp_idf_mode="$(grep '^esp_idf_mode=' "$GITHUB_OUTPUT" | tail -n1 | cut -d= -f2-)"
+ arduino_mode="$(grep '^arduino_mode=' "$GITHUB_OUTPUT" | tail -n1 | cut -d= -f2-)"
+ firmware_paths="$(grep '^firmware_paths=' "$GITHUB_OUTPUT" | tail -n1 | cut -d= -f2-)"
+ unknown_paths="$(grep '^unknown_paths=' "$GITHUB_OUTPUT" | tail -n1 | cut -d= -f2-)"
+ {
+ echo "### CI scope"
+ echo "- ESP-IDF mode: $esp_idf_mode"
+ echo "- Arduino mode: $arduino_mode"
+ echo "- Firmware paths: $firmware_paths"
+ echo "- Unknown paths: $unknown_paths"
+ } >> "$GITHUB_STEP_SUMMARY"
+
discover-esp-idf:
+ needs: scope
+ if: needs.scope.outputs.esp_idf_mode != 'none'
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.discover.outputs.matrix }}
@@ -48,12 +95,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- id: discover
+ shell: bash
run: |
- python3 scripts/discover_examples.py \
- --surface esp-idf \
+ args=()
+ if [[ "${{ needs.scope.outputs.esp_idf_mode }}" == "selected" ]]; then
+ args+=(--selected-paths '${{ needs.scope.outputs.esp_idf_paths }}')
+ fi
+ python3 scripts/discover_examples.py --surface esp-idf \
--selector "${{ github.event.inputs.target || 'all' }}" \
- --idf-versions "${IDF_VERSIONS}" \
- --github-output "$GITHUB_OUTPUT"
+ --idf-versions "${IDF_VERSIONS}" --github-output "$GITHUB_OUTPUT" "${args[@]}"
build-esp-idf:
needs: discover-esp-idf
@@ -74,23 +124,19 @@ jobs:
- name: Package firmware
shell: bash
run: |
- python3 releases/package_firmware.py \
- --framework esp-idf \
- --project "${{ matrix.path }}" \
- --build-dir "build/${{ matrix.name }}-${{ matrix.idf }}" \
- --name "${PRODUCT_NAME}-${{ matrix.name }}-${{ matrix.idf }}" \
- --framework-version "${{ matrix.idf }}" \
- --target "${IDF_TARGET}" \
- --git-sha "${{ github.sha }}" \
- --output-dir release-artifacts
- - name: Upload firmware artifact
- uses: actions/upload-artifact@v4
+ python3 releases/package_firmware.py --framework esp-idf --project "${{ matrix.path }}" \
+ --build-dir "build/${{ matrix.name }}-${{ matrix.idf }}" --name "${PRODUCT_NAME}-${{ matrix.name }}-${{ matrix.idf }}" \
+ --framework-version "${{ matrix.idf }}" --target "${IDF_TARGET}" --git-sha "${{ github.sha }}" --output-dir release-artifacts
+ - uses: actions/upload-artifact@v4
with:
name: firmware-esp-idf-${{ matrix.name }}-${{ matrix.idf }}
path: release-artifacts/*.zip
if-no-files-found: error
+ retention-days: 14
discover-arduino:
+ needs: scope
+ if: needs.scope.outputs.arduino_mode != 'none'
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.discover.outputs.matrix }}
@@ -98,13 +144,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- id: discover
+ shell: bash
run: |
- python3 scripts/discover_examples.py \
- --surface arduino \
+ args=()
+ if [[ "${{ needs.scope.outputs.arduino_mode }}" == "selected" ]]; then
+ args+=(--selected-paths '${{ needs.scope.outputs.arduino_paths }}')
+ fi
+ python3 scripts/discover_examples.py --surface arduino \
--selector "${{ github.event.inputs.target || 'all' }}" \
- --arduino-core "${ARDUINO_CORE_VERSION}" \
- --fqbn "${ARDUINO_FQBN}" \
- --github-output "$GITHUB_OUTPUT"
+ --arduino-core "${ARDUINO_CORE_VERSION}" --fqbn "${ARDUINO_FQBN}" --github-output "$GITHUB_OUTPUT" "${args[@]}"
build-arduino:
needs: discover-arduino
@@ -124,40 +172,45 @@ jobs:
arduino-cli core install "esp32:esp32@${{ matrix.core }}"
- name: Compile ${{ matrix.path }}
run: |
- arduino-cli compile \
- --fqbn "${{ matrix.fqbn }}" \
- --libraries "${{ matrix.libraries }}" \
- --export-binaries \
- --output-dir "build/${{ matrix.name }}-${{ matrix.core }}" \
- "${{ matrix.path }}"
+ arduino-cli compile --fqbn "${{ matrix.fqbn }}" --libraries "${{ matrix.libraries }}" \
+ --export-binaries --output-dir "build/${{ matrix.name }}-${{ matrix.core }}" "${{ matrix.path }}"
- name: Package firmware
run: |
- python3 releases/package_firmware.py \
- --framework arduino \
- --project "${{ matrix.path }}" \
- --build-dir "build/${{ matrix.name }}-${{ matrix.core }}" \
- --name "${PRODUCT_NAME}-${{ matrix.name }}-arduino-${{ matrix.core }}" \
- --framework-version "${{ matrix.core }}" \
- --target "${IDF_TARGET}" \
- --git-sha "${{ github.sha }}" \
- --output-dir release-artifacts
- - name: Upload firmware artifact
- uses: actions/upload-artifact@v4
+ python3 releases/package_firmware.py --framework arduino --project "${{ matrix.path }}" \
+ --build-dir "build/${{ matrix.name }}-${{ matrix.core }}" --name "${PRODUCT_NAME}-${{ matrix.name }}-arduino-${{ matrix.core }}" \
+ --framework-version "${{ matrix.core }}" --target "${IDF_TARGET}" --git-sha "${{ github.sha }}" --output-dir release-artifacts
+ - uses: actions/upload-artifact@v4
with:
name: firmware-arduino-${{ matrix.name }}-${{ matrix.core }}
path: release-artifacts/*.zip
if-no-files-found: error
+ retention-days: 14
+
+ validation:
+ name: Example validation
+ if: always()
+ needs: [quality, scope, discover-esp-idf, build-esp-idf, discover-arduino, build-arduino]
+ runs-on: ubuntu-latest
+ steps:
+ - name: Report stable validation status
+ shell: bash
+ run: |
+ results=("${{ needs.quality.result }}" "${{ needs.scope.result }}" "${{ needs.discover-esp-idf.result }}" "${{ needs.build-esp-idf.result }}" "${{ needs.discover-arduino.result }}" "${{ needs.build-arduino.result }}")
+ printf 'Job results: %s\n' "${results[*]}"
+ for result in "${results[@]}"; do
+ if [[ "$result" == "failure" || "$result" == "cancelled" ]]; then
+ exit 1
+ fi
+ done
+
publish-release:
permissions:
contents: write
- needs:
- - build-esp-idf
- - build-arduino
- if: startsWith(github.ref, 'refs/tags/v')
+ needs: [validation, build-esp-idf, build-arduino]
+ if: startsWith(github.ref, 'refs/tags/v') && needs.validation.result == 'success'
runs-on: ubuntu-latest
steps:
- - name: Download firmware artifacts
- uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v4
with:
pattern: firmware-*
path: release-artifacts
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c246ffdc..f7b3cb57 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,5 +1,7 @@
# Contributing
+[简体中文](CONTRIBUTING_ZH.md)
+
Thank you for helping improve this board repository.
## Workflow
diff --git a/CONTRIBUTING_ZH.md b/CONTRIBUTING_ZH.md
new file mode 100644
index 00000000..7c7b8b65
--- /dev/null
+++ b/CONTRIBUTING_ZH.md
@@ -0,0 +1,18 @@
+# 贡献指南
+
+[English](CONTRIBUTING.md)
+
+感谢您帮助改进本开发板仓库。
+
+## 流程
+
+1. Fork 仓库并创建主题分支。
+2. 将改动集中在一个缺陷、示例或文档更新上。
+3. 路径、设置或行为变化时更新受影响的文档。
+4. 提交包含简明说明和验证记录的 Pull Request。
+
+## 验证
+
+Pull Request 应使用 GitHub Actions 进行构建验证。硬件行为发生变化时,请说明板卡版本、示例
+路径和已执行的手工检查。ESP-IDF 示例位于 `examples/esp-idf/`,Arduino 草图和随附库位于
+`examples/arduino/`;随附库中的示例默认不属于产品 CI。
diff --git a/FirmWare/README.md b/FirmWare/README.md
index 7e5d72c6..b6c1cd36 100644
--- a/FirmWare/README.md
+++ b/FirmWare/README.md
@@ -1,7 +1,9 @@
# Firmware
+[简体中文](README_ZH.md)
+
This directory contains checked-in factory or recovery firmware images for the board.
These binaries are not generated by CI and are not treated as source projects by the build workflow. Use them only when the product documentation or support flow calls for flashing a released binary image.
-Source-built firmware artifacts are produced by the `Build Examples` workflow from maintained ESP-IDF and Arduino source projects. Use the helpers in `releases/` to package local build outputs or download CI artifacts.
\ No newline at end of file
+Source-built firmware artifacts are produced by the `Build Examples` workflow from maintained ESP-IDF and Arduino source projects. Use the helpers in `releases/` to package local build outputs or download CI artifacts.
diff --git a/FirmWare/README_ZH.md b/FirmWare/README_ZH.md
new file mode 100644
index 00000000..5889030e
--- /dev/null
+++ b/FirmWare/README_ZH.md
@@ -0,0 +1,9 @@
+# 固件
+
+[English](README.md)
+
+本目录包含该开发板已提交的出厂或恢复固件映像。它们不由 CI 生成,也不被构建工作流视为源码
+项目。仅在产品文档或支持流程要求刷写已发布二进制文件时使用。
+
+维护中的 ESP-IDF 和 Arduino 源码项目会由 `Build Examples` 工作流生成固件工件;可使用
+`releases/` 中的工具打包构建输出或下载 CI 工件。
diff --git a/Material/images/ESP32-S3-Touch-AMOLED-2.06.jpg b/Material/images/ESP32-S3-Touch-AMOLED-2.06.jpg
new file mode 100644
index 00000000..c0628477
Binary files /dev/null and b/Material/images/ESP32-S3-Touch-AMOLED-2.06.jpg differ
diff --git a/README.md b/README.md
index 8a4c6c81..60a4597e 100644
--- a/README.md
+++ b/README.md
@@ -6,65 +6,57 @@
+ 简体中文
- Product Page ·
- Firmware Releases ·
- ESP-IDF Examples ·
- Arduino Examples ·
- Documentation
+ 🌐 Product ·
+ 📚 Documentation ·
+ 📦 Firmware ·
+ 🧩 ESP-IDF ·
+ 🔧 Arduino
+
---
-## Overview
+## ✨ Overview
This repository provides first-party ESP-IDF and Arduino examples, source-built
-firmware packages, factory recovery firmware, schematics, and product media
-for the Waveshare ESP32-S3-Touch-AMOLED-2.06.
+firmware packages, factory recovery firmware, schematics, and product media for
+the Waveshare ESP32-S3-Touch-AMOLED-2.06.
-The board combines an ESP32-S3 with a 410 x 502 AMOLED display, capacitive
-touch, motion sensing, power management, and audio interfaces in a compact
-watch-style development platform.
-
-## Hardware Overview
+## 🖥️ Hardware Overview
| Feature | Device / interface |
| --- | --- |
| MCU | ESP32-S3 32-bit LX7 dual-core processor |
| Display | 2.06-inch 410 x 502 QSPI AMOLED using CO5300 |
-| Touch | CST9220 capacitive touch controller using the CST92xx driver |
+| Touch | FT3168 capacitive touch controller using an FT3x68-compatible driver at I2C address `0x38` |
| Power management | AXP2101 |
| Motion sensor | QMI8658 six-axis IMU |
| Audio | Dual digital microphones with ES7210 ADC and ES8311 codec |
-| Board support | Managed component: `waveshare/esp32_s3_touch_amoled_2_06` |
+| Board support | Managed component `waveshare/esp32_s3_touch_amoled_2_06` |
| Hardware files | [Schematic](Schematic/) and [product material](Material/) |
-## Firmware Releases
+## 📦 Firmware Releases
-The fastest way to try an example is to use a ready-to-flash package from the
-[latest release](https://github.com/waveshareteam/ESP32-S3-Touch-AMOLED-2.06/releases/latest)
+Use a ready-to-flash package from the [latest release](https://github.com/waveshareteam/ESP32-S3-Touch-AMOLED-2.06/releases/latest)
or a completed `Build Examples` workflow run.
-1. Download the `firmware-*` archive for the example and framework version you need.
-2. Extract the archive and install esptool with `python -m pip install esptool`.
+1. Download the `firmware-*` archive for the required example and framework version.
+2. Extract it and install esptool with `python -m pip install esptool`.
3. Connect the board over USB.
4. Run `flash.bat COMx` on Windows or `./flash.sh /dev/ttyUSB0` on Linux.
5. Reset the board if it does not restart automatically.
-> [!NOTE]
-> Each package contains a combined firmware image at offset `0x0`, the source
-> segments, flash arguments, helper scripts, and a manifest.
-
-Factory recovery images under [FirmWare](FirmWare/) are separate from
-CI-generated example firmware. See [Firmware Artifacts](docs/firmware.md) for
-the distinction between the two sources.
+Each package contains a combined firmware image at offset `0x0`, source
+segments, flash arguments, helper scripts, and a manifest. Factory recovery
+files under [FirmWare](FirmWare/) are separate immutable artifacts; see
+[Firmware Artifacts](docs/firmware.md).
-## Examples
+## 🧪 Examples
-### ESP-IDF
-
-| Example | Focus |
+| ESP-IDF example | Focus |
| --- | --- |
| [01_AXP2101](examples/esp-idf/01_AXP2101/) | Power management and battery telemetry |
| [02_lvgl_demo_v9](examples/esp-idf/02_lvgl_demo_v9/) | LVGL 9 display and touch demo |
@@ -73,9 +65,7 @@ the distinction between the two sources.
| [05_Spec_Analyzer](examples/esp-idf/05_Spec_Analyzer/) | Microphone spectrum analyzer |
| [06_videoplayer](examples/esp-idf/06_videoplayer/) | SD card video playback with audio |
-### Arduino
-
-| Example | Focus |
+| Arduino example | Focus |
| --- | --- |
| [01_HelloWorld](examples/arduino/01_HelloWorld/) | Display bring-up |
| [02_GFX_AsciiTable](examples/arduino/02_GFX_AsciiTable/) | GFX text and character rendering |
@@ -86,25 +76,22 @@ the distinction between the two sources.
| [07_LVGL_SD_Test](examples/arduino/07_LVGL_SD_Test/) | SD card test |
| [08_ES8311](examples/arduino/08_ES8311/) | ES8311 audio codec example |
-Bundled Arduino libraries live under
-[`examples/arduino/libraries`](examples/arduino/libraries/). Their upstream
-library examples are intentionally excluded from the product CI matrix.
+Bundled libraries in [`examples/arduino/libraries/`](examples/arduino/libraries/)
+are used by product sketches; their upstream examples are excluded from product CI.
-## Supported Toolchains
+## 🛠️ Supported Toolchains
| Surface | Version |
| --- | --- |
-| ESP-IDF | `v5.5.4` |
-| ESP-IDF | `v6.0.2` |
-| Arduino-ESP32 | `3.3.10` |
+| ESP-IDF | `v5.5.5` and `v6.0.2` |
+| Arduino-ESP32 | `3.3.11` |
The [Build Examples workflow](https://github.com/waveshareteam/ESP32-S3-Touch-AMOLED-2.06/actions/workflows/examples.yml)
-discovers and builds maintained first-party ESP-IDF projects and Arduino
-sketches, then packages successful builds as flashable firmware artifacts. See
-[Continuous Integration](docs/ci.md) for the build matrix, dispatch inputs,
-and artifact behavior.
+discovers maintained first-party projects, packages successful builds, and
+accepts `target=all`, an example name, or an example path for manual runs. See
+[Continuous Integration](docs/ci.md) for routing and matrix details.
-## Repository Layout
+## 🗂️ Repository Layout
| Path | Purpose |
| --- | --- |
@@ -117,24 +104,24 @@ and artifact behavior.
| [`scripts/`](scripts/) | Example discovery and CI helper scripts |
| [`docs/`](docs/) | Repository, CI, and firmware notes |
-## Documentation
+## 📚 Documentation
- [Repository Structure](docs/repository-structure.md)
- [Continuous Integration](docs/ci.md)
- [Firmware Artifacts](docs/firmware.md)
- [Release Tools](releases/README.md)
-## Support and Contributions
+## 🤝 Support and Contributions
Contributions and reproducible issue reports are welcome. Include the example
-path, framework version, reproduction steps, expected behavior, actual behavior,
-and relevant serial logs.
+path, framework version, reproduction steps, expected behavior, actual
+behavior, and relevant serial logs.
- [Contributing Guide](CONTRIBUTING.md)
- [Support](SUPPORT.md)
- [Security Policy](SECURITY.md)
- [Open an Issue](https://github.com/waveshareteam/ESP32-S3-Touch-AMOLED-2.06/issues/new/choose)
-## License
+## 📄 License
-This repository is licensed under the Apache License 2.0. See [LICENSE](LICENSE).
\ No newline at end of file
+This repository is licensed under the Apache License 2.0. See [LICENSE](LICENSE).
diff --git a/README_ZH.md b/README_ZH.md
new file mode 100644
index 00000000..4e21babc
--- /dev/null
+++ b/README_ZH.md
@@ -0,0 +1,123 @@
+
+
+---
+
+## ✨ 概览
+
+本仓库提供适用于 Waveshare ESP32-S3-Touch-AMOLED-2.06 的第一方 ESP-IDF 和
+Arduino 示例、源码构建固件包、出厂恢复固件、原理图和产品资料。
+
+## 🖥️ 硬件概览
+
+| 功能 | 器件 / 接口 |
+| --- | --- |
+| MCU | ESP32-S3 32 位 LX7 双核处理器 |
+| 显示 | 2.06 英寸 410 x 502 QSPI AMOLED,使用 CO5300 |
+| 触摸 | FT3168 电容触摸控制器,使用兼容 FT3x68 的驱动,I2C 地址为 `0x38` |
+| 电源管理 | AXP2101 |
+| 运动传感器 | QMI8658 六轴 IMU |
+| 音频 | 双数字麦克风、ES7210 ADC 和 ES8311 编解码器 |
+| 板级支持 | 托管组件 `waveshare/esp32_s3_touch_amoled_2_06` |
+| 硬件文件 | [原理图](Schematic/)和[产品资料](Material/) |
+
+## 📦 固件发布
+
+可从[最新发布](https://github.com/waveshareteam/ESP32-S3-Touch-AMOLED-2.06/releases/latest)
+或已完成的 `Build Examples` 工作流运行获取可刷写包。
+
+1. 下载所需示例和框架版本对应的 `firmware-*` 归档包。
+2. 解压后使用 `python -m pip install esptool` 安装 esptool。
+3. 通过 USB 连接开发板。
+4. Windows 运行 `flash.bat COMx`,Linux 运行 `./flash.sh /dev/ttyUSB0`。
+5. 如未自动重启,请重置开发板。
+
+每个包都包含偏移 `0x0` 的合并固件映像、源码段、刷写参数、辅助脚本和清单。
+[FirmWare](FirmWare/) 下的出厂恢复文件是独立的不可变工件;详见
+[固件工件](docs/firmware_ZH.md)。
+
+## 🧪 示例
+
+| ESP-IDF 示例 | 重点 |
+| --- | --- |
+| [01_AXP2101](examples/esp-idf/01_AXP2101/) | 电源管理和电池遥测 |
+| [02_lvgl_demo_v9](examples/esp-idf/02_lvgl_demo_v9/) | LVGL 9 显示与触摸演示 |
+| [03_esp-brookesia](examples/esp-idf/03_esp-brookesia/) | ESP-Brookesia 应用界面 |
+| [04_Immersive_block](examples/esp-idf/04_Immersive_block/) | 运动驱动的 LVGL 方块演示 |
+| [05_Spec_Analyzer](examples/esp-idf/05_Spec_Analyzer/) | 麦克风频谱分析仪 |
+| [06_videoplayer](examples/esp-idf/06_videoplayer/) | 带音频的 SD 卡视频播放 |
+
+| Arduino 示例 | 重点 |
+| --- | --- |
+| [01_HelloWorld](examples/arduino/01_HelloWorld/) | 显示初始化 |
+| [02_GFX_AsciiTable](examples/arduino/02_GFX_AsciiTable/) | GFX 文本和字符渲染 |
+| [03_LVGL_PCF85063_simpleTime](examples/arduino/03_LVGL_PCF85063_simpleTime/) | 使用 PCF85063 RTC 的 LVGL 时钟界面 |
+| [04_LVGL_QMI8658_ui](examples/arduino/04_LVGL_QMI8658_ui/) | LVGL IMU 数据界面 |
+| [05_LVGL_AXP2101_ADC_Data](examples/arduino/05_LVGL_AXP2101_ADC_Data/) | LVGL 电源和电池遥测界面 |
+| [06_LVGL_Arduino_v9](examples/arduino/06_LVGL_Arduino_v9/) | LVGL 9 界面演示 |
+| [07_LVGL_SD_Test](examples/arduino/07_LVGL_SD_Test/) | SD 卡测试 |
+| [08_ES8311](examples/arduino/08_ES8311/) | ES8311 音频编解码器示例 |
+
+[`examples/arduino/libraries/`](examples/arduino/libraries/) 中的随附库由产品草图使用;
+其中的上游示例不属于产品 CI。
+
+## 🛠️ 支持的工具链
+
+| 平台 | 版本 |
+| --- | --- |
+| ESP-IDF | `v5.5.5` 和 `v6.0.2` |
+| Arduino-ESP32 | `3.3.11` |
+
+[Build Examples 工作流](https://github.com/waveshareteam/ESP32-S3-Touch-AMOLED-2.06/actions/workflows/examples.yml)
+会发现维护中的第一方项目并打包成功构建;手动运行可使用 `target=all`、示例名称或示例路径。
+详见[持续集成](docs/ci_ZH.md)。
+
+## 🗂️ 仓库布局
+
+| 路径 | 用途 |
+| --- | --- |
+| [`examples/esp-idf/`](examples/esp-idf/) | 第一方 ESP-IDF 项目 |
+| [`examples/arduino/`](examples/arduino/) | 第一方 Arduino 草图和随附库 |
+| [`FirmWare/`](FirmWare/) | 已提交的出厂和恢复二进制文件 |
+| [`Material/`](Material/) | 产品媒体和参考资料 |
+| [`Schematic/`](Schematic/) | 原理图文件 |
+| [`releases/`](releases/) | 固件打包和工件下载工具 |
+| [`scripts/`](scripts/) | 示例发现和 CI 辅助脚本 |
+| [`docs/`](docs/) | 仓库、CI 和固件说明 |
+
+## 📚 文档
+
+- [仓库结构](docs/repository-structure_ZH.md)
+- [持续集成](docs/ci_ZH.md)
+- [固件工件](docs/firmware_ZH.md)
+- [发布工具](releases/README_ZH.md)
+
+## 🤝 支持与贡献
+
+欢迎贡献和提交可复现的问题报告。请包含示例路径、框架版本、复现步骤、预期行为、实际行为和相关
+串口日志。
+
+- [贡献指南](CONTRIBUTING_ZH.md)
+- [支持](SUPPORT_ZH.md)
+- [安全策略](SECURITY_ZH.md)
+- [提交问题](https://github.com/waveshareteam/ESP32-S3-Touch-AMOLED-2.06/issues/new/choose)
+
+## 📄 许可证
+
+本仓库采用 Apache License 2.0,详见 [LICENSE](LICENSE)。
diff --git a/SECURITY.md b/SECURITY.md
index 46e98ea3..bc779007 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,10 +1,12 @@
# Security Policy
+[简体中文](SECURITY_ZH.md)
+
## Reporting A Vulnerability
Please do not disclose security vulnerabilities in public issues.
-Report vulnerabilities through Waveshare's official support channel with:
+Report vulnerabilities through the private [Waveshare service ticket channel](https://service.waveshare.com/) with:
- Affected repository and example path.
- A clear description of the issue.
diff --git a/SECURITY_ZH.md b/SECURITY_ZH.md
new file mode 100644
index 00000000..716ad4e0
--- /dev/null
+++ b/SECURITY_ZH.md
@@ -0,0 +1,10 @@
+# 安全策略
+
+[English](SECURITY.md)
+
+## 漏洞报告
+
+请勿在公开 Issues 中披露安全漏洞。请通过私密的 [Waveshare 服务工单渠道](https://service.waveshare.com/)
+报告,并提供受影响仓库和示例路径、问题说明、复现步骤或概念验证、影响以及已知缓解措施。
+
+维护者会审阅报告,并在适当时协调修复或公告。
diff --git a/SUPPORT.md b/SUPPORT.md
index a4f29d73..2026a890 100644
--- a/SUPPORT.md
+++ b/SUPPORT.md
@@ -1,6 +1,8 @@
# Support
-For product usage questions, check the product wiki and repository issues first.
+[简体中文](SUPPORT_ZH.md)
+
+For product usage questions, first check the [official product wiki](https://www.waveshare.com/wiki/ESP32-S3-Touch-AMOLED-2.06) and repository issues.
When opening an issue, include:
@@ -10,4 +12,4 @@ When opening an issue, include:
- Steps to reproduce.
- Relevant serial logs, build logs, or screenshots.
-For order-specific or hardware replacement questions, contact Waveshare support through the official support channel and include the order information requested by support.
+For order-specific, hardware replacement, or private support questions, use the [Waveshare service ticket channel](https://service.waveshare.com/). The [official support documentation](https://docs.waveshare.com/support) lists additional support options.
diff --git a/SUPPORT_ZH.md b/SUPPORT_ZH.md
new file mode 100644
index 00000000..fb1bd18d
--- /dev/null
+++ b/SUPPORT_ZH.md
@@ -0,0 +1,10 @@
+# 支持
+
+[English](SUPPORT.md)
+
+产品使用问题请先查阅[官方产品 Wiki](https://www.waveshare.com/wiki/ESP32-S3-Touch-AMOLED-2.06)
+和仓库 Issues。提交问题时请包含板卡名称与版本、示例路径、框架和版本、复现步骤,以及相关的
+串口日志、构建日志或截图。
+
+订单、硬件更换或私密支持问题,请使用 [Waveshare 服务工单渠道](https://service.waveshare.com/)。
+[官方支持文档](https://docs.waveshare.com/support)列出了其他支持方式。
diff --git a/config/ci-routing.json b/config/ci-routing.json
new file mode 100644
index 00000000..fb86aba7
--- /dev/null
+++ b/config/ci-routing.json
@@ -0,0 +1,16 @@
+{
+ "build_override_patterns": [],
+ "documentation_patterns": [],
+ "documentation_asset_patterns": [
+ "Material/images/ESP32-S3-Touch-AMOLED-2.06.jpg"
+ ],
+ "ignore_build_patterns": [],
+ "firmware_patterns": [
+ "FirmWare/**"
+ ],
+ "esp_idf_shared_patterns": [],
+ "arduino_shared_patterns": [],
+ "esp_idf_global_patterns": [],
+ "arduino_global_patterns": [],
+ "global_build_patterns": []
+}
diff --git a/config/markdown-audit.json b/config/markdown-audit.json
new file mode 100644
index 00000000..443d9ef4
--- /dev/null
+++ b/config/markdown-audit.json
@@ -0,0 +1,56 @@
+{
+ "classification_rules": [],
+ "exclude_patterns": [],
+ "pair_exempt_patterns": [],
+ "language_link_exempt_patterns": [],
+ "relative_link_ignore_patterns": [],
+ "docs_only_allowed_patterns": [
+ "Material/images/ESP32-S3-Touch-AMOLED-2.06.jpg"
+ ],
+ "sensitive_allow_regexes": [],
+ "bilingual_pairs": [],
+ "bilingual_directory_mappings": [],
+ "homepage_h3_emoji_allow_patterns": [],
+ "homepage_pairs": [
+ {
+ "english": "README.md",
+ "chinese": "README_ZH.md",
+ "profile": "single-product",
+ "required_components": [
+ "centered_header",
+ "html_h1",
+ "subtitle",
+ "badges",
+ "language_switch",
+ "quick_links",
+ "hero_image",
+ "separator",
+ "h2"
+ ],
+ "required_quick_links": [
+ "product",
+ "documentation",
+ "firmware",
+ "esp_idf",
+ "arduino"
+ ],
+ "required_badges": [
+ "build",
+ "release",
+ "license"
+ ],
+ "required_h2_icons": [
+ "✨",
+ "🖥️",
+ "📦",
+ "🧪",
+ "🛠️",
+ "🗂️",
+ "📚",
+ "🤝",
+ "📄"
+ ],
+ "h3_emoji_allow_patterns": []
+ }
+ ]
+}
diff --git a/docs/ci.md b/docs/ci.md
index 28dff5f9..4914f95b 100644
--- a/docs/ci.md
+++ b/docs/ci.md
@@ -1,5 +1,7 @@
# CI
+[简体中文](ci_ZH.md)
+
This repository uses the `Build Examples` GitHub Actions workflow for build validation and firmware artifact packaging.
## ESP-IDF
@@ -8,7 +10,7 @@ The workflow discovers first-party projects under `examples/esp-idf/`.
The default matrix builds each discovered project for ESP32-S3 against:
-- ESP-IDF `v5.5.4`
+- ESP-IDF `v5.5.5`
- ESP-IDF `v6.0.2`
## Arduino
@@ -19,7 +21,7 @@ Bundled library examples under `examples/arduino/libraries/` are intentionally e
The default Arduino matrix uses:
-- Arduino-ESP32 core `3.3.10`
+- Arduino-ESP32 core `3.3.11`
- FQBN `esp32:esp32:esp32s3:USBMode=hwcdc,CDCOnBoot=cdc,PSRAM=opi,FlashSize=16M,PartitionScheme=app3M_fat9M_16MB`
- Bundled libraries from `examples/arduino/libraries/`
@@ -27,10 +29,21 @@ The default Arduino matrix uses:
Start the workflow manually with `target=all`, an example directory name such as `01_AXP2101`, or a repo-relative path such as `examples/arduino/01_HelloWorld`.
+## Documentation Checks
+
+The lightweight repository checker runs the focused homepage, bilingual-link,
+and local-link checks configured in `config/markdown-audit.json`:
+
+```text
+python3 scripts/check_docs.py . --config config/markdown-audit.json
+```
+
+It is intentionally a limited repository checker, not a complete Markdown audit.
+
## Firmware Artifacts
Successful source builds are packaged with `releases/package_firmware.py` and uploaded as `firmware-*` workflow artifacts. Each archive contains a manifest, flash helper scripts, flash arguments, and firmware binaries.
-Checked-in factory binaries under `FirmWare/` are recovery or release artifacts, not source projects. They are documented and excluded from build discovery.
+Checked-in factory binaries under `FirmWare/` are recovery or release artifacts, not source projects. They are documented and excluded from build discovery. A lightweight quality and scope job is always visible; complete documentation-only changes skip expensive example builds by design, while unavailable diff data fails closed.
-See `releases/README.md` and `docs/firmware.md` for packaging and artifact download details.
\ No newline at end of file
+See `releases/README.md` and `docs/firmware.md` for packaging and artifact download details.
diff --git a/docs/ci_ZH.md b/docs/ci_ZH.md
new file mode 100644
index 00000000..6d56e25a
--- /dev/null
+++ b/docs/ci_ZH.md
@@ -0,0 +1,35 @@
+# 持续集成
+
+[English](ci.md)
+
+本仓库使用 `Build Examples` GitHub Actions 工作流验证构建并打包源码构建的固件工件。
+
+## ESP-IDF
+
+工作流发现 `examples/esp-idf/` 下的第一方项目,并在 ESP32-S3 上使用 ESP-IDF
+`v5.5.5` 和 `v6.0.2` 构建。
+
+## 文档检查
+
+轻量级仓库检查器会按 `config/markdown-audit.json` 运行针对主页、双语链接和本地链接的检查:
+
+```text
+python3 scripts/check_docs.py . --config config/markdown-audit.json
+```
+
+该检查器有意保持为范围有限的仓库检查器,并非完整的 Markdown 审核。
+
+## Arduino
+
+工作流发现 `examples/arduino/` 下的第一方草图,使用 Arduino-ESP32 `3.3.11`、既有
+FQBN 选项和 `examples/arduino/libraries/` 中的随附库。库内上游示例不属于产品 CI。
+
+## 手动运行
+
+可用 `target=all`、示例目录名或仓库相对示例路径手动启动工作流。
+
+## 工件和路由
+
+成功构建由 `releases/package_firmware.py` 打包为 `firmware-*` 工件。轻量质量和范围
+作业始终可见;完整的仅文档变更会按设计跳过昂贵的示例构建,缺少或不可用的差异会失败。
+`FirmWare/` 中的文件单独报告,不进入示例发现或构建。
diff --git a/docs/firmware.md b/docs/firmware.md
index 298102d5..cdc9853c 100644
--- a/docs/firmware.md
+++ b/docs/firmware.md
@@ -1,5 +1,7 @@
# Firmware Artifacts
+[简体中文](firmware_ZH.md)
+
This repository has two firmware artifact sources:
- Checked-in factory or recovery binaries under `FirmWare/`
diff --git a/docs/firmware_ZH.md b/docs/firmware_ZH.md
new file mode 100644
index 00000000..e5da01bc
--- /dev/null
+++ b/docs/firmware_ZH.md
@@ -0,0 +1,12 @@
+# 固件工件
+
+[English](firmware.md)
+
+本仓库有两类固件工件:`FirmWare/` 下已提交的出厂或恢复二进制文件,以及由
+`Build Examples` GitHub Actions 工作流生成的源码构建归档包。
+
+出厂二进制文件用于产品恢复和支持流程,不由 CI 构建、重新打包或重新上传。CI 仅构建维护中
+的第一方 ESP-IDF 项目和 Arduino 草图,并生成包含 `manifest.json`、`flash.sh`、`flash.bat`、
+`flash_args.txt` 与 `bin/combined.bin` 的 `firmware-*` 工件。
+
+请使用 `releases/download_artifacts.py` 下载已完成工作流的工件。生成和下载的归档包由 Git 忽略。
diff --git a/docs/repository-structure.md b/docs/repository-structure.md
index c6cfb022..b14e9017 100644
--- a/docs/repository-structure.md
+++ b/docs/repository-structure.md
@@ -1,6 +1,8 @@
# Repository Structure
-This repository uses the canonical Waveshare ESP32 product layout for maintained examples:
+[简体中文](repository-structure_ZH.md)
+
+This repository uses the maintained product layout:
```text
examples/esp-idf/ first-party ESP-IDF projects
@@ -11,18 +13,20 @@ Schematic/ schematic PDF
releases/ CI firmware packaging and artifact download helpers
```
-The CI discovery script treats the first-party example roots as product examples and excludes examples nested inside bundled libraries.
-
-Historical versioned paths before this migration were:
-
-` ext
-examples/ESP-IDF-v5.4.2/
-examples/Arduino-v3.2.0/
-`$([Environment]::NewLine)
-Use the canonical paths above for all maintained source, CI, and release packaging.
+The CI discovery script treats the first-party example roots as product
+examples and excludes examples nested inside bundled libraries. Historical
+versioned paths are retained only as migration history; use the paths above for
+maintained source, CI, and release packaging.
-## Component Notes
+## Component Ownership
-Several ESP-IDF examples still contain local reusable components such as `XPowersLib`, `bsp_extra`, and Brookesia components. Prefer managed Waveshare or Espressif components when a compatible release exists for the selected ESP-IDF matrix.
+The bundled Arduino `XPowersLib` is retained because no equivalent managed
+replacement was established for this repository. Product-local `bsp_extra`
+remains because it supplies the audio/video adapter and board glue used by its
+examples. The Brookesia tree is owned with the Brookesia example and is not a
+generic board-support replacement.
-Keep local `bsp_extra` code only for board-specific glue or temporary adaptation that is not ready for a shared component.
\ No newline at end of file
+The `FirmWare/` directory is an immutable delivery boundary for this
+repository. It currently contains one checked-in binary and no source project
+or release archive; binaries and artifacts are inventoried separately and
+never enter the example build matrix.
diff --git a/docs/repository-structure_ZH.md b/docs/repository-structure_ZH.md
new file mode 100644
index 00000000..f52b9432
--- /dev/null
+++ b/docs/repository-structure_ZH.md
@@ -0,0 +1,25 @@
+# 仓库结构
+
+[English](repository-structure.md)
+
+本仓库的维护目录如下:
+
+```text
+examples/esp-idf/ 第一方 ESP-IDF 项目
+examples/arduino/ 第一方 Arduino 草图和随附库
+FirmWare/ 已提交的出厂或恢复二进制文件
+Material/ 产品媒体和资料
+Schematic/ 原理图 PDF
+releases/ CI 固件打包和下载工具
+```
+
+CI 仅将第一方示例根目录识别为产品示例,并排除随附库内的示例。请使用以上维护路径。
+
+## 组件归属
+
+随附的 Arduino `XPowersLib` 会保留,因为尚未确认适用于本仓库的等价托管替代品。产品本地的
+`bsp_extra` 保留为其示例提供音频/视频适配器和板级胶合代码。Brookesia 树归 Brookesia 示例
+所有,并不是通用板级支持的替代品。
+
+`FirmWare/` 是不可变交付边界。当前其中只有一个已提交的二进制文件,没有源码项目或发布归档;
+二进制文件和工件会被单独清点,绝不会进入示例构建矩阵。
diff --git a/examples/esp-idf/01_AXP2101/sdkconfig.defaults b/examples/esp-idf/01_AXP2101/sdkconfig.defaults
index 05f1157f..137e6580 100644
--- a/examples/esp-idf/01_AXP2101/sdkconfig.defaults
+++ b/examples/esp-idf/01_AXP2101/sdkconfig.defaults
@@ -1,6 +1,3 @@
CONFIG_PMU_I2C_SCL=14
CONFIG_PMU_I2C_SDA=15
CONFIG_PMU_INTERRUPT_PIN=-1
-CONFIG_PMU_I2C_SCL=14
-CONFIG_PMU_I2C_SDA=15
-CONFIG_PMU_INTERRUPT_PIN=-1
diff --git a/examples/esp-idf/02_lvgl_demo_v9/main/idf_component.yml b/examples/esp-idf/02_lvgl_demo_v9/main/idf_component.yml
index 0f0e9bf6..98c753ca 100644
--- a/examples/esp-idf/02_lvgl_demo_v9/main/idf_component.yml
+++ b/examples/esp-idf/02_lvgl_demo_v9/main/idf_component.yml
@@ -6,7 +6,8 @@ dependencies:
espressif/usb:
version: "^1.4.1"
waveshare/esp32_s3_touch_amoled_2_06:
- version: "*"
+ # v2 is the current board BSP line; review a future major with both IDF lines and hardware.
+ version: "^2.0.0"
lvgl/lvgl:
version: "9.5.0"
public: true
diff --git a/examples/esp-idf/03_esp-brookesia/main/idf_component.yml b/examples/esp-idf/03_esp-brookesia/main/idf_component.yml
index f67e4b39..38f9a175 100644
--- a/examples/esp-idf/03_esp-brookesia/main/idf_component.yml
+++ b/examples/esp-idf/03_esp-brookesia/main/idf_component.yml
@@ -4,4 +4,5 @@ dependencies:
version: "^1.4.1"
lvgl/lvgl:
version: "9.5.0"
- waveshare/esp32_s3_touch_amoled_2_06: "*"
+ # v2 is the current board BSP line; review a future major with both IDF lines and hardware.
+ waveshare/esp32_s3_touch_amoled_2_06: "^2.0.0"
diff --git a/examples/esp-idf/04_Immersive_block/main/idf_component.yml b/examples/esp-idf/04_Immersive_block/main/idf_component.yml
index 29d2f1df..f202a5ac 100644
--- a/examples/esp-idf/04_Immersive_block/main/idf_component.yml
+++ b/examples/esp-idf/04_Immersive_block/main/idf_component.yml
@@ -10,7 +10,8 @@ dependencies:
waveshare/qmi8658:
version: "*"
- waveshare/esp32_s3_touch_amoled_2_06: "*"
+ # v2 is the current board BSP line; review a future major with both IDF lines and hardware.
+ waveshare/esp32_s3_touch_amoled_2_06: "^2.0.0"
lvgl/lvgl:
version: "9.5.0"
public: true
diff --git a/examples/esp-idf/05_Spec_Analyzer/components/bsp_extra/idf_component.yml b/examples/esp-idf/05_Spec_Analyzer/components/bsp_extra/idf_component.yml
index d9560b58..ee617971 100644
--- a/examples/esp-idf/05_Spec_Analyzer/components/bsp_extra/idf_component.yml
+++ b/examples/esp-idf/05_Spec_Analyzer/components/bsp_extra/idf_component.yml
@@ -7,7 +7,8 @@ dependencies:
version: '>=5.3.2'
waveshare/esp32_s3_touch_amoled_2_06:
- version: "*"
+ # v2 is the current board BSP line; review a future major with both IDF lines and hardware.
+ version: "^2.0.0"
chmorgan/esp-audio-player:
version: "1.1.*"
diff --git a/examples/esp-idf/06_videoplayer/components/bsp_extra/idf_component.yml b/examples/esp-idf/06_videoplayer/components/bsp_extra/idf_component.yml
index d9560b58..ee617971 100644
--- a/examples/esp-idf/06_videoplayer/components/bsp_extra/idf_component.yml
+++ b/examples/esp-idf/06_videoplayer/components/bsp_extra/idf_component.yml
@@ -7,7 +7,8 @@ dependencies:
version: '>=5.3.2'
waveshare/esp32_s3_touch_amoled_2_06:
- version: "*"
+ # v2 is the current board BSP line; review a future major with both IDF lines and hardware.
+ version: "^2.0.0"
chmorgan/esp-audio-player:
version: "1.1.*"
diff --git a/releases/README.md b/releases/README.md
index 3e945194..768e292a 100644
--- a/releases/README.md
+++ b/releases/README.md
@@ -1,5 +1,7 @@
# Release Scripts
+[简体中文](README_ZH.md)
+
This directory contains helper scripts for packaging build outputs into flashable firmware archives.
## ESP-IDF
@@ -27,14 +29,14 @@ arduino-cli compile \
--fqbn esp32:esp32:esp32s3 \
--libraries examples/arduino/libraries \
--export-binaries \
- --output-dir build/01_HelloWorld-3.3.10 \
+ --output-dir build/01_HelloWorld-3.3.11 \
examples/arduino/01_HelloWorld
python3 releases/package_firmware.py \
--framework arduino \
--project examples/arduino/01_HelloWorld \
- --build-dir build/01_HelloWorld-3.3.10 \
- --framework-version 3.3.10 \
+ --build-dir build/01_HelloWorld-3.3.11 \
+ --framework-version 3.3.11 \
--target esp32s3
```
diff --git a/releases/README_ZH.md b/releases/README_ZH.md
new file mode 100644
index 00000000..d1c82193
--- /dev/null
+++ b/releases/README_ZH.md
@@ -0,0 +1,11 @@
+# 发布脚本
+
+[English](README.md)
+
+本目录提供将构建输出打包成可刷写固件归档包的辅助脚本。ESP-IDF 和 Arduino 示例构建完成后,
+使用 `releases/package_firmware.py` 生成包含 `manifest.json`、刷写脚本、刷写参数及
+`bin/combined.bin` 的归档包。
+
+Arduino 示例使用既有 ESP32-S3 FQBN 选项、随附库与 Arduino-ESP32 `3.3.11`。可使用
+`releases/download_artifacts.py` 下载已完成 CI 运行的工件;推送 `v*` 标签会将生成的
+`firmware-*` 归档包发布到对应 GitHub Release。
diff --git a/scripts/check_docs.py b/scripts/check_docs.py
new file mode 100644
index 00000000..792b51d8
--- /dev/null
+++ b/scripts/check_docs.py
@@ -0,0 +1,273 @@
+#!/usr/bin/env python3
+"""Check the repository's documented bilingual and local-link conventions.
+
+This modest repository documentation checker intentionally covers only the
+listed first-party pages, reciprocal language links, local links/fragments,
+and homepage H2 icon symmetry. It is not a complete Markdown audit.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+from pathlib import Path
+
+PAIRS = {
+ "README.md": "README_ZH.md",
+ "docs/ci.md": "docs/ci_ZH.md",
+ "docs/firmware.md": "docs/firmware_ZH.md",
+ "docs/repository-structure.md": "docs/repository-structure_ZH.md",
+ "FirmWare/README.md": "FirmWare/README_ZH.md",
+ "releases/README.md": "releases/README_ZH.md",
+ "CONTRIBUTING.md": "CONTRIBUTING_ZH.md",
+ "SUPPORT.md": "SUPPORT_ZH.md",
+ "SECURITY.md": "SECURITY_ZH.md",
+ ".github/ISSUE_TEMPLATE/bug_report.md": ".github/ISSUE_TEMPLATE/bug_report_ZH.md",
+ ".github/pull_request_template.md": ".github/pull_request_template_ZH.md",
+}
+LINK = re.compile(r"!?\[[^]]*\]\(([^)\s]+)(?:\s+[^)]*)?\)")
+HEADING = re.compile(r"^#{1,6}\s+(.+?)\s*$", re.MULTILINE)
+HTML_IMAGE = re.compile(r"
[^>]*)>", re.IGNORECASE)
+HTML_ATTRIBUTE = re.compile(r'''([\w:-]+)\s*=\s*["']([^"']*)["']''')
+HTML_LINK = re.compile(r''']*\bhref\s*=\s*["']([^"']+)["']''', re.IGNORECASE)
+
+QUICK_LINK_TARGETS = {
+ "product": re.compile(r"https://www\.waveshare\.(?:com|net)/", re.IGNORECASE),
+ "documentation": re.compile(r"^docs/ci(?:_ZH)?\.md$"),
+ "firmware": re.compile(r"^FirmWare/README(?:_ZH)?\.md$"),
+ "esp_idf": re.compile(r"^examples/esp-idf/?$"),
+ "arduino": re.compile(r"^examples/arduino/?$"),
+}
+BADGE_ALT_TEXT = {
+ "build": "Build Examples",
+ "release": "Latest Release",
+ "license": "License",
+}
+COMPONENT_NAMES = {
+ "centered_header",
+ "html_h1",
+ "subtitle",
+ "badges",
+ "language_switch",
+ "quick_links",
+ "hero_image",
+ "separator",
+ "h2",
+}
+
+
+def slug(text: str) -> str:
+ value = re.sub(r"[`*_~]", "", text).strip().lower()
+ value = re.sub(r"[^\w\-\u4e00-\u9fff ]", "", value)
+ return re.sub(r"\s+", "-", value).strip("-")
+
+
+def anchors(text: str) -> set[str]:
+ return {slug(match.group(1)) for match in HEADING.finditer(text)}
+
+
+def local_links(text: str) -> list[str]:
+ return [target for target in LINK.findall(text) if not re.match(r"(?:https?:|mailto:)", target)]
+
+
+def load_homepage_pairs(config_path: Path) -> list[dict[str, object]]:
+ """Load the small homepage-contract subset used by this checker."""
+ try:
+ config = json.loads(config_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as error:
+ raise ValueError(f"cannot read config: {error}") from error
+ if not isinstance(config, dict):
+ raise ValueError("config root must be an object")
+ homepage_pairs = config.get("homepage_pairs")
+ if not isinstance(homepage_pairs, list) or not homepage_pairs:
+ raise ValueError("config homepage_pairs must be a non-empty list")
+ for index, homepage in enumerate(homepage_pairs):
+ if not isinstance(homepage, dict):
+ raise ValueError(f"config homepage_pairs[{index}] must be an object")
+ for key in ("english", "chinese", "profile"):
+ if not isinstance(homepage.get(key), str) or not homepage[key]:
+ raise ValueError(f"config homepage_pairs[{index}].{key} must be a non-empty string")
+ if homepage["profile"] not in {"single-product", "multi-product-hub", "auto"}:
+ raise ValueError(f"config homepage_pairs[{index}].profile is invalid")
+ for key in ("required_components", "required_quick_links", "required_badges", "required_h2_icons"):
+ if not isinstance(homepage.get(key), list) or not all(isinstance(value, str) for value in homepage[key]):
+ raise ValueError(f"config homepage_pairs[{index}].{key} must be a string list")
+ unknown_components = set(homepage["required_components"]) - COMPONENT_NAMES
+ if unknown_components:
+ raise ValueError(f"config homepage_pairs[{index}] has unknown components: {sorted(unknown_components)}")
+ unknown_links = set(homepage["required_quick_links"]) - set(QUICK_LINK_TARGETS)
+ if unknown_links:
+ raise ValueError(f"config homepage_pairs[{index}] has unknown quick links: {sorted(unknown_links)}")
+ unknown_badges = set(homepage["required_badges"]) - set(BADGE_ALT_TEXT)
+ if unknown_badges:
+ raise ValueError(f"config homepage_pairs[{index}] has unknown badges: {sorted(unknown_badges)}")
+ return homepage_pairs
+
+
+def image_attributes(match: re.Match[str]) -> dict[str, str]:
+ return {name.lower(): value for name, value in HTML_ATTRIBUTE.findall(match.group("attributes"))}
+
+
+def check_homepage(
+ repo: Path, relative: str, text: str, counterpart: str, homepage: dict[str, object]
+) -> list[str]:
+ errors: list[str] = []
+ header = re.search(r"]*>", text, re.IGNORECASE)
+ header_end = text.find("
", header.end()) if header else -1
+ if not header or header_end < 0:
+ return [f"missing centered header: {relative}"]
+ header_text = text[header.start() : header_end + len("")]
+ header_offset = header.start()
+ positions: dict[str, int | None] = {"centered_header": header.start()}
+ h1 = re.search(r"\s*\S.*?
", header_text, re.IGNORECASE | re.DOTALL)
+ subtitle = re.search(r"\s*\S.*?", header_text, re.IGNORECASE | re.DOTALL)
+ positions["html_h1"] = header_offset + h1.start() if h1 else None
+ positions["subtitle"] = header_offset + subtitle.start() if subtitle else None
+
+ image_matches = list(HTML_IMAGE.finditer(header_text))
+ badge_positions: list[int] = []
+ for role in homepage["required_badges"]:
+ badge = next(
+ (
+ match
+ for match in image_matches
+ if image_attributes(match).get("alt") == BADGE_ALT_TEXT[role]
+ ),
+ None,
+ )
+ if badge is None:
+ errors.append(f"missing {role} badge: {relative}")
+ else:
+ badge_positions.append(header_offset + badge.start())
+ positions["badges"] = min(badge_positions) if badge_positions else None
+
+ language_link = re.search(
+ rf''']*\bhref\s*=\s*["']{re.escape(counterpart)}["']''', header_text, re.IGNORECASE
+ )
+ positions["language_switch"] = header_offset + language_link.start() if language_link else None
+
+ links = [(match.group(1), header_offset + match.start()) for match in HTML_LINK.finditer(header_text)]
+ quick_positions: list[int] = []
+ for role in homepage["required_quick_links"]:
+ quick_link = next((position for href, position in links if QUICK_LINK_TARGETS[role].search(href)), None)
+ if quick_link is None:
+ errors.append(f"missing {role} quick link: {relative}")
+ else:
+ quick_positions.append(quick_link)
+ positions["quick_links"] = min(quick_positions) if quick_positions else None
+
+ hero_position: int | None = None
+ for image in image_matches:
+ attributes = image_attributes(image)
+ source = attributes.get("src", "")
+ if not source or re.match(r"(?:https?:|data:)", source, re.IGNORECASE) or "badge" in source.lower():
+ continue
+ if not attributes.get("alt", "").strip():
+ errors.append(f"hero image has empty alt text: {relative}")
+ continue
+ target = ((repo / relative).parent / source).resolve()
+ try:
+ target.relative_to(repo.resolve())
+ except ValueError:
+ errors.append(f"repository-escaping hero image: {relative} -> {source}")
+ continue
+ if not target.is_file():
+ errors.append(f"missing hero image target: {relative} -> {source}")
+ continue
+ hero_position = header_offset + image.start()
+ break
+ positions["hero_image"] = hero_position
+
+ separator = re.search(r"^---\s*$", text[header_end + len("") :], re.MULTILINE)
+ positions["separator"] = header_end + len("") + separator.start() if separator else None
+ first_h2 = re.search(r"^##\s+", text, re.MULTILINE)
+ positions["h2"] = first_h2.start() if first_h2 else None
+ h2_icons = re.findall(r"^##\s+(\S+)", text, re.MULTILINE)
+ expected_icons = homepage["required_h2_icons"]
+ if h2_icons != expected_icons:
+ errors.append(f"homepage H2 icon/order mismatch: {relative}")
+
+ required_components = homepage["required_components"]
+ for component in required_components:
+ if positions[component] is None:
+ errors.append(f"missing {component}: {relative}")
+ configured_positions = [positions[component] for component in required_components if positions[component] is not None]
+ if configured_positions != sorted(configured_positions):
+ errors.append(f"homepage component order mismatch: {relative}")
+ return errors
+
+
+def check(
+ repo: Path, pairs: dict[str, str] = PAIRS, homepage_pairs: list[dict[str, object]] | None = None
+) -> list[str]:
+ errors: list[str] = []
+ contents: dict[str, str] = {}
+ for english, chinese in pairs.items():
+ for relative in (english, chinese):
+ file = repo / relative
+ if not file.is_file():
+ errors.append(f"missing paired page: {relative}")
+ else:
+ contents[relative] = file.read_text(encoding="utf-8")
+ chinese_link = os.path.relpath(chinese, Path(english).parent).replace("\\", "/")
+ english_link = os.path.relpath(english, Path(chinese).parent).replace("\\", "/")
+ if english in contents and chinese_link not in contents[english]:
+ errors.append(f"missing reciprocal language link: {english} -> {chinese}")
+ if chinese in contents and english_link not in contents[chinese]:
+ errors.append(f"missing reciprocal language link: {chinese} -> {english}")
+ for relative, text in contents.items():
+ for target in local_links(text):
+ file_part, _, fragment = target.partition("#")
+ target_file = (repo / relative).parent / file_part if file_part else repo / relative
+ target_file = target_file.resolve()
+ try:
+ target_file.relative_to(repo.resolve())
+ except ValueError:
+ errors.append(f"repository-escaping link: {relative} -> {target}")
+ continue
+ if not target_file.exists():
+ errors.append(f"missing local link: {relative} -> {target}")
+ elif fragment and target_file.suffix.lower() == ".md":
+ try:
+ target_text = target_file.read_text(encoding="utf-8")
+ except UnicodeDecodeError:
+ continue
+ if slug(fragment) not in anchors(target_text):
+ errors.append(f"missing local fragment: {relative} -> {target}")
+ english_h2 = re.findall(r"^##\s+(\S+)", contents.get("README.md", ""), re.MULTILINE)
+ chinese_h2 = re.findall(r"^##\s+(\S+)", contents.get("README_ZH.md", ""), re.MULTILINE)
+ if english_h2 != chinese_h2:
+ errors.append("homepage H2 icon/order mismatch")
+ for homepage in homepage_pairs or []:
+ english = homepage["english"]
+ chinese = homepage["chinese"]
+ for relative, counterpart in ((english, chinese), (chinese, english)):
+ file = repo / relative
+ if not file.is_file():
+ errors.append(f"missing configured homepage: {relative}")
+ continue
+ errors.extend(check_homepage(repo, relative, file.read_text(encoding="utf-8"), counterpart, homepage))
+ return errors
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("repo", nargs="?", default=".")
+ parser.add_argument("--config", required=True, type=Path, help="homepage contract JSON")
+ args = parser.parse_args()
+ try:
+ homepage_pairs = load_homepage_pairs(args.config)
+ except ValueError as error:
+ parser.error(str(error))
+ errors = check(Path(args.repo).resolve(), homepage_pairs=homepage_pairs)
+ if errors:
+ print("\n".join(f"check_docs: {error}" for error in errors))
+ return 1
+ print("check_docs: passed")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/ci_scope.py b/scripts/ci_scope.py
new file mode 100644
index 00000000..cc94e631
--- /dev/null
+++ b/scripts/ci_scope.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+"""Classify repository changes for example CI routing.
+
+This is a repository CI routing helper, not a complete policy or documentation
+audit. It accepts Git name-status data and emits GitHub Actions outputs.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from dataclasses import dataclass, field
+from pathlib import PurePosixPath
+
+
+@dataclass
+class Scope:
+ esp_idf: set[str] = field(default_factory=set)
+ arduino: set[str] = field(default_factory=set)
+ esp_all: bool = False
+ arduino_all: bool = False
+ firmware: list[str] = field(default_factory=list)
+ unknown: list[str] = field(default_factory=list)
+
+ def mark_all(self) -> None:
+ self.esp_all = True
+ self.arduino_all = True
+
+
+def normalize(path: str) -> str:
+ return path.replace("\\", "/").strip("/")
+
+
+def parse_name_status(data: bytes) -> list[str]:
+ """Return every affected path, retaining both sides of renames/deletions."""
+ if not data:
+ raise ValueError("empty changed-file data")
+ if b"\0" not in data:
+ paths: list[str] = []
+ for line in data.decode("utf-8", "surrogateescape").splitlines():
+ fields = line.split("\t")
+ if len(fields) < 2 or not fields[0]:
+ raise ValueError("expected git --name-status data")
+ paths.append(normalize(fields[1]))
+ if fields[0][0] in {"R", "C"}:
+ if len(fields) < 3:
+ raise ValueError("rename/copy status missing destination path")
+ paths.append(normalize(fields[2]))
+ if not paths:
+ raise ValueError("no changed paths")
+ return paths
+ fields = [item.decode("utf-8", "surrogateescape") for item in data.split(b"\0") if item]
+ paths: list[str] = []
+ index = 0
+ while index < len(fields):
+ field = fields[index]
+ # `git diff --name-status -z` emits STATUS NUL PATH NUL (and two
+ # paths for renames/copies); tolerate the tab form used by fixtures.
+ if "\t" in field:
+ status, path = field.split("\t", 1)
+ else:
+ status = field
+ index += 1
+ if index >= len(fields):
+ raise ValueError("status missing path")
+ path = fields[index]
+ if not status:
+ raise ValueError("missing name-status code")
+ paths.append(normalize(path))
+ index += 1
+ if status[0] in {"R", "C"}:
+ if index >= len(fields):
+ raise ValueError("rename/copy status missing destination path")
+ paths.append(normalize(fields[index]))
+ index += 1
+ if not paths:
+ raise ValueError("no changed paths")
+ return paths
+
+
+def project_for(path: str, surface: str) -> str | None:
+ parts = PurePosixPath(path).parts
+ prefix = ("examples", surface)
+ if tuple(parts[:2]) != prefix or len(parts) < 3:
+ return None
+ if surface == "arduino" and parts[2] == "libraries":
+ return None
+ return "/".join(parts[:3])
+
+
+def classify(paths: list[str]) -> Scope:
+ scope = Scope()
+ for path in paths:
+ lower = path.lower()
+ if path.startswith("FirmWare/"):
+ scope.firmware.append(path)
+ continue
+ if path.startswith(("Material/", "Schematic/")):
+ continue
+ if lower.endswith((".md", ".markdown", ".txt")):
+ continue
+ if path.startswith((".github/workflows/", "scripts/", "tests/", "config/")):
+ scope.mark_all()
+ continue
+ if path.startswith("releases/"):
+ if lower.endswith((".py", ".sh", ".yml", ".yaml", ".json")):
+ scope.mark_all()
+ continue
+ if path.startswith("examples/arduino/libraries/"):
+ scope.arduino_all = True
+ continue
+ if path.startswith(("examples/esp-idf/common/", "examples/esp-idf/components/")):
+ scope.esp_all = True
+ continue
+ esp_project = project_for(path, "esp-idf")
+ if esp_project:
+ scope.esp_idf.add(esp_project)
+ continue
+ arduino_project = project_for(path, "arduino")
+ if arduino_project:
+ scope.arduino.add(arduino_project)
+ continue
+ scope.unknown.append(path)
+ scope.mark_all()
+ return scope
+
+
+def payload(scope: Scope) -> dict[str, str]:
+ def mode(all_selected: bool, paths: set[str]) -> str:
+ return "all" if all_selected else ("selected" if paths else "none")
+
+ return {
+ "esp_idf_mode": mode(scope.esp_all, scope.esp_idf),
+ "arduino_mode": mode(scope.arduino_all, scope.arduino),
+ "esp_idf_paths": json.dumps(sorted(scope.esp_idf), separators=(",", ":")),
+ "arduino_paths": json.dumps(sorted(scope.arduino), separators=(",", ":")),
+ "firmware_paths": json.dumps(sorted(scope.firmware), separators=(",", ":")),
+ "unknown_paths": json.dumps(sorted(scope.unknown), separators=(",", ":")),
+ }
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--name-status-file", type=argparse.FileType("rb"), default=sys.stdin.buffer)
+ parser.add_argument("--github-output", type=argparse.FileType("a", encoding="utf-8"))
+ args = parser.parse_args()
+ try:
+ result = payload(classify(parse_name_status(args.name_status_file.read())))
+ except ValueError as exc:
+ print(f"ci_scope: {exc}", file=sys.stderr)
+ return 2
+ lines = [f"{key}={value}" for key, value in result.items()]
+ if args.github_output:
+ args.github_output.write("\n".join(lines) + "\n")
+ else:
+ print("\n".join(lines))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/discover_examples.py b/scripts/discover_examples.py
index c1bc8f82..c1bb83d6 100644
--- a/scripts/discover_examples.py
+++ b/scripts/discover_examples.py
@@ -105,12 +105,25 @@ def discover_arduino(repo: Path) -> list[dict[str, str]]:
def build_matrix(args: argparse.Namespace) -> dict[str, list[dict[str, str]]]:
repo = Path(args.repo).resolve()
selector = normalize(args.selector)
+ selected_paths = None
+ if args.selected_paths:
+ try:
+ value = json.loads(args.selected_paths)
+ except json.JSONDecodeError as exc:
+ raise SystemExit(f"--selected-paths must be JSON: {exc}") from exc
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
+ raise SystemExit("--selected-paths must be a JSON array of paths")
+ selected_paths = {normalize(item) for item in value}
if args.surface == "esp-idf":
projects = [entry for entry in discover_esp_idf(repo) if selector_matches(entry, selector)]
+ if selected_paths is not None:
+ projects = [entry for entry in projects if normalize(entry["path"]) in selected_paths]
versions = [item.strip() for item in args.idf_versions.split(",") if item.strip()]
include = [entry | {"idf": version} for entry in projects for version in versions]
else:
sketches = [entry for entry in discover_arduino(repo) if selector_matches(entry, selector)]
+ if selected_paths is not None:
+ sketches = [entry for entry in sketches if normalize(entry["path"]) in selected_paths]
include = [entry | {"core": args.arduino_core, "fqbn": args.fqbn} for entry in sketches]
return {"include": include}
@@ -120,9 +133,10 @@ def main() -> None:
parser.add_argument("--repo", default=".")
parser.add_argument("--surface", choices=("esp-idf", "arduino"), required=True)
parser.add_argument("--selector", default="all")
- parser.add_argument("--idf-versions", default="v5.5.4,v6.0.2")
- parser.add_argument("--arduino-core", default="3.3.10")
+ parser.add_argument("--idf-versions", default="v5.5.5,v6.0.2")
+ parser.add_argument("--arduino-core", default="3.3.11")
parser.add_argument("--fqbn", default="esp32:esp32:esp32s3")
+ parser.add_argument("--selected-paths", help="exact JSON array of repo-relative paths")
parser.add_argument("--github-output")
args = parser.parse_args()
@@ -138,4 +152,4 @@ def main() -> None:
if __name__ == "__main__":
- main()
\ No newline at end of file
+ main()
diff --git a/tests/test_check_docs.py b/tests/test_check_docs.py
new file mode 100644
index 00000000..a7c3a761
--- /dev/null
+++ b/tests/test_check_docs.py
@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+import check_docs # noqa: E402
+
+
+class DocumentationCheckerTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.temp = tempfile.TemporaryDirectory()
+ self.repo = Path(self.temp.name)
+ self.pairs = {"README.md": "README_ZH.md"}
+ self.homepage = {
+ "english": "README.md", "chinese": "README_ZH.md", "profile": "single-product",
+ "required_components": ["centered_header", "html_h1", "subtitle", "badges", "language_switch", "quick_links", "hero_image", "separator", "h2"],
+ "required_quick_links": ["product", "documentation", "firmware", "esp_idf", "arduino"],
+ "required_badges": ["build", "release", "license"], "required_h2_icons": ["\u2728"],
+ }
+
+ def tearDown(self) -> None:
+ self.temp.cleanup()
+
+ def write_pair(self, english: str, chinese: str) -> None:
+ (self.repo / "README.md").write_text(english, encoding="utf-8")
+ (self.repo / "README_ZH.md").write_text(chinese, encoding="utf-8")
+
+ def write_homepage_pair(self, hero_source: str = "Material/images/hero.jpg") -> None:
+ image = self.repo / "Material" / "images" / "hero.jpg"
+ image.parent.mkdir(parents=True, exist_ok=True)
+ image.write_bytes(b"test image")
+ for relative, counterpart, product, docs, firmware in (
+ ("README.md", "README_ZH.md", "https://www.waveshare.com/product.htm", "docs/ci.md", "FirmWare/README.md"),
+ ("README_ZH.md", "README.md", "https://www.waveshare.net/shop/product.htm", "docs/ci_ZH.md", "FirmWare/README_ZH.md"),
+ ):
+ (self.repo / relative).write_text(
+ f'''
+
+---
+
+## \u2728 Overview
+''',
+ encoding="utf-8",
+ )
+
+ def check_homepages(self) -> list[str]:
+ return check_docs.check(self.repo, self.pairs, [self.homepage])
+
+ def test_pair_links_fragments_and_h2_icons_pass(self) -> None:
+ self.write_pair("[中文](README_ZH.md) [local](#section)\n\n## 📋 Section\n", "[English](README.md)\n\n## 📋 部分\n")
+ self.assertEqual(check_docs.check(self.repo, self.pairs), [])
+
+ def test_missing_pair_reciprocal_link_and_h2_mismatch_fail(self) -> None:
+ (self.repo / "README.md").write_text("## 📋 Section\n", encoding="utf-8")
+ errors = check_docs.check(self.repo, self.pairs)
+ self.assertTrue(any("missing paired page" in error for error in errors))
+ self.write_pair("[中文](README_ZH.md)\n\n## 📋 Section\n", "[English](README.md)\n\n## 🧩 部分\n")
+ errors = check_docs.check(self.repo, self.pairs)
+ self.assertTrue(any("H2 icon/order" in error for error in errors))
+
+ def test_configured_homepage_passes(self) -> None:
+ self.write_homepage_pair()
+ self.assertEqual(self.check_homepages(), [])
+
+ def test_configured_homepage_requires_hero_image(self) -> None:
+ self.write_homepage_pair()
+ for relative in self.pairs:
+ page = self.repo / relative
+ page.write_text(
+ page.read_text(encoding="utf-8").replace(
+ '
', ""
+ ),
+ encoding="utf-8",
+ )
+ self.assertTrue(any("missing hero_image" in error for error in self.check_homepages()))
+
+ def test_configured_homepage_requires_product_link(self) -> None:
+ self.write_homepage_pair()
+ for relative in self.pairs:
+ page = self.repo / relative
+ page.write_text(page.read_text(encoding="utf-8").replace("https://www.waveshare", "https://example.invalid"), encoding="utf-8")
+ self.assertTrue(any("missing product quick link" in error for error in self.check_homepages()))
+
+ def test_configured_homepage_rejects_escaping_hero_target(self) -> None:
+ self.write_homepage_pair("../outside.jpg")
+ self.assertTrue(any("repository-escaping hero image" in error for error in self.check_homepages()))
+
+ def test_workflow_uses_exact_configured_checker_command(self) -> None:
+ root = Path(__file__).resolve().parents[1]
+ workflow = (root / ".github" / "workflows" / "examples.yml").read_text(encoding="utf-8")
+ self.assertIn("python3 scripts/check_docs.py . --config config/markdown-audit.json", workflow)
+ result = subprocess.run(
+ [sys.executable, "scripts/check_docs.py", ".", "--config", "config/markdown-audit.json"],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+
+ def test_missing_relative_target_is_reported(self) -> None:
+ self.write_pair("[中文](README_ZH.md) [missing](nope.md)\n\n## 📋 Section\n", "[English](README.md)\n\n## 📋 部分\n")
+ self.assertTrue(any("missing local link" in error for error in check_docs.check(self.repo, self.pairs)))
diff --git a/tests/test_ci_scope.py b/tests/test_ci_scope.py
new file mode 100644
index 00000000..793f8028
--- /dev/null
+++ b/tests/test_ci_scope.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+import sys
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+import ci_scope # noqa: E402
+
+
+def changed(*fields: str) -> list[str]:
+ return ci_scope.parse_name_status("\0".join(fields).encode() + b"\0")
+
+
+class ScopeTests(unittest.TestCase):
+ def route(self, *fields: str) -> ci_scope.Scope:
+ return ci_scope.classify(changed(*fields))
+
+ def test_markdown_never_builds_examples(self) -> None:
+ for path in (
+ "README.md",
+ "examples/esp-idf/01_AXP2101/README.md",
+ "examples/arduino/01_HelloWorld/README.md",
+ "examples/arduino/libraries/XPowersLib/README.md",
+ ):
+ scope = self.route("M", path)
+ self.assertFalse(scope.esp_all or scope.arduino_all or scope.esp_idf or scope.arduino)
+
+ def test_direct_sources_select_only_their_entry(self) -> None:
+ idf = self.route("M", "examples/esp-idf/01_AXP2101/main/main.c")
+ self.assertEqual(idf.esp_idf, {"examples/esp-idf/01_AXP2101"})
+ self.assertFalse(idf.arduino or idf.arduino_all)
+ arduino = self.route("M", "examples/arduino/01_HelloWorld/01_HelloWorld.ino")
+ self.assertEqual(arduino.arduino, {"examples/arduino/01_HelloWorld"})
+ self.assertFalse(arduino.esp_idf or arduino.esp_all)
+
+ def test_library_and_global_inputs_expand_only_expected_surfaces(self) -> None:
+ library = self.route("M", "examples/arduino/libraries/XPowersLib/src/a.cpp")
+ self.assertTrue(library.arduino_all)
+ self.assertFalse(library.esp_all)
+ for path in (".github/workflows/examples.yml", "scripts/ci_scope.py", "tests/test_ci_scope.py", "releases/package_firmware.py"):
+ global_scope = self.route("M", path)
+ self.assertTrue(global_scope.esp_all and global_scope.arduino_all)
+
+ def test_shared_esp_idf_roots_select_all_esp_idf_projects(self) -> None:
+ for path in ("examples/esp-idf/common/compat.c", "examples/esp-idf/components/board_glue.c"):
+ scope = self.route("M", path)
+ self.assertTrue(scope.esp_all)
+ self.assertFalse(scope.arduino_all)
+
+ def test_firmware_and_assets_are_reported_or_ignored_without_builds(self) -> None:
+ for path in ("FirmWare/README.md", "FirmWare/a.c", "FirmWare/sdkconfig", "FirmWare/a.bin", "FirmWare/a.zip"):
+ scope = self.route("M", path)
+ self.assertEqual(scope.firmware, [path])
+ self.assertFalse(scope.esp_all or scope.arduino_all)
+ for path in ("Material/photo.png", "Schematic/board.pdf"):
+ scope = self.route("M", path)
+ self.assertFalse(scope.esp_all or scope.arduino_all)
+
+ def test_rename_deletion_and_unknown_paths_are_conservative(self) -> None:
+ renamed = self.route("R100", "examples/esp-idf/01_AXP2101/main/main.c", "docs/note.md")
+ self.assertEqual(renamed.esp_idf, {"examples/esp-idf/01_AXP2101"})
+ self.assertEqual(
+ ci_scope.parse_name_status(b"R100\texamples/arduino/01_HelloWorld/a.ino\tdocs/note.md\n"),
+ ["examples/arduino/01_HelloWorld/a.ino", "docs/note.md"],
+ )
+ unknown = self.route("D", "unclassified/build.input")
+ self.assertTrue(unknown.esp_all and unknown.arduino_all)
+ self.assertEqual(unknown.unknown, ["unclassified/build.input"])
+
+ def test_empty_or_unavailable_data_fails_closed(self) -> None:
+ with self.assertRaises(ValueError):
+ ci_scope.parse_name_status(b"")
+ result = subprocess.run(
+ [sys.executable, str(Path(ci_scope.__file__))],
+ input=b"",
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ self.assertEqual(result.returncode, 2)
+
+ def test_workflow_cli_shape_writes_outputs(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ changed_file = root / "changed-files.z"
+ output_file = root / "github-output"
+ changed_file.write_bytes(b"M\0examples/arduino/01_HelloWorld/01_HelloWorld.ino\0")
+ result = subprocess.run(
+ [
+ sys.executable,
+ str(Path(ci_scope.__file__)),
+ "--name-status-file",
+ str(changed_file),
+ "--github-output",
+ str(output_file),
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ self.assertEqual(result.returncode, 0)
+ outputs = output_file.read_text(encoding="utf-8")
+ self.assertIn("esp_idf_mode=none", outputs)
+ self.assertIn("arduino_mode=selected", outputs)
+ self.assertIn('arduino_paths=["examples/arduino/01_HelloWorld"]', outputs)
diff --git a/tests/test_discover_examples.py b/tests/test_discover_examples.py
new file mode 100644
index 00000000..13233b76
--- /dev/null
+++ b/tests/test_discover_examples.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+import argparse
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+import discover_examples # noqa: E402
+
+
+class DiscoveryTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.temp = tempfile.TemporaryDirectory()
+ self.repo = Path(self.temp.name)
+ (self.repo / "examples/esp-idf/idf-one/main").mkdir(parents=True)
+ (self.repo / "examples/esp-idf/idf-one/CMakeLists.txt").write_text("project(test)")
+ (self.repo / "examples/arduino/sketch-one").mkdir(parents=True)
+ (self.repo / "examples/arduino/sketch-one/sketch-one.ino").write_text("void setup() {}")
+ (self.repo / "examples/arduino/libraries").mkdir(parents=True)
+
+ def tearDown(self) -> None:
+ self.temp.cleanup()
+
+ def test_exact_selected_paths_filter_without_changing_selector(self) -> None:
+ args = argparse.Namespace(repo=self.repo, surface="esp-idf", selector="all", idf_versions="v5.5.5,v6.0.2", arduino_core="3.3.11", fqbn="fqbn", selected_paths='["examples/esp-idf/idf-one"]')
+ self.assertEqual(len(discover_examples.build_matrix(args)["include"]), 2)
+ args.selected_paths = "[]"
+ self.assertEqual(discover_examples.build_matrix(args)["include"], [])
+
+ def test_default_versions_are_current(self) -> None:
+ parser = argparse.ArgumentParser()
+ # The CLI defaults are intentionally asserted from source-facing parser behavior.
+ self.assertIn("v5.5.5", Path(discover_examples.__file__).read_text(encoding="utf-8"))
+ self.assertIn('default="3.3.11"', Path(discover_examples.__file__).read_text(encoding="utf-8"))
+