diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 924c916..03f9e33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,12 +11,12 @@ permissions: jobs: esp32s3-build: - name: ESP-IDF 5.3.5 / ESP32-S3 ${{ matrix.profile }} + name: ESP-IDF 5.3.5 / ESP32-S3 ${{ matrix.profile }} / baseline runs-on: ubuntu-latest strategy: fail-fast: false matrix: - profile: [n8r8, tinys3d] + profile: [n8r8, n16r8, tinys3d] steps: - name: Check out repository uses: actions/checkout@v6 @@ -26,7 +26,72 @@ jobs: esp_idf_version: v5.3.5 target: esp32s3 path: . - command: bash ci/build-esp32s3.sh ${{ matrix.profile }} + command: bash ci/build-esp32s3.sh ${{ matrix.profile }} baseline + + fan-characterization-configured: + name: ESP-IDF 5.3.5 / ESP32-S3 ${{ matrix.board }} / fan-characterization (compile fixture) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # CI COMPILE FIXTURES ONLY. These GPIO numbers exist so hosted CI + # compiles the real fan-characterization backend. They are NOT + # DragonBench reference wiring, NOT JumpJet wiring, and NOT hardware + # recommendations; no fixture has been wired. Each set passes the + # firmware's reserved/conflict guards for its board (TinyS3[D] GPIO38 + # RF switch, N16R8 GPIO48 onboard RGB LED, Octal PSRAM 33-37 on N8R8 + # and N16R8, strapping, USB, flash, console). n8r8 and tinys3d use + # opposite gate sink levels so both stage polarities compile. n16r8 + # matches the DragonBench Rev 0 bench fixture wiring (GPIO6 gate of a + # non-inverting N-channel stage, GPIO7 tach): DragonBench compile/bench + # fixture wiring only, not JumpJet GPIOs, product requirements, or fan + # safety thresholds. PPR stays unknown. + include: + - board: tinys3d + gate_gpio: 4 + gate_sink_level: 1 + tach_gpio: 5 + - board: n8r8 + gate_gpio: 6 + gate_sink_level: 0 + tach_gpio: 7 + - board: n16r8 + gate_gpio: 6 + gate_sink_level: 1 + tach_gpio: 7 + steps: + - name: Check out repository + uses: actions/checkout@v6 + - name: Write compile-only fixture wiring (runner workspace only, never committed) + run: | + cat > sdkconfig.fan-characterization.local <- - gcc -std=c11 -Wall -Wextra -Wpedantic -Werror - -fsanitize=address,undefined -fno-omit-frame-pointer - -Ifirmware/common/include - firmware/common/db_run.c firmware/common/db_network.c tests/native/test_db_run.c - -o test_db_run - - name: Execute state-machine test - run: ./test_db_run + run: | + for test in test_db_run test_db_fan; do + gcc -std=c11 -Wall -Wextra -Wpedantic -Werror \ + -fsanitize=address,undefined -fno-omit-frame-pointer ${{ matrix.defines }} \ + -Ifirmware/common/include \ + firmware/common/db_run.c firmware/common/db_network.c firmware/common/db_fan.c \ + tests/native/$test.c -o $test + done + - name: Execute state-machine and fan-characterization tests + run: | + ./test_db_run + ./test_db_fan diff --git a/.gitignore b/.gitignore index 5fbbc68..3b3cf2d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ __pycache__/ .venv/ .pytest_cache/ artifacts/ +sdkconfig.fan-characterization.local +test_db_fan +test_db_fan.exe +idf-build.log diff --git a/README.md b/README.md index ace6d47..2072464 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,28 @@ rail stability, and reset/brownout behavior. It is not JumpJet or DragonBreath firmware, a performance benchmark, a generic HAL, a device-control application, or an authority for product safety limits. -The firmware contains no heater or fan GPIOs and exposes no actuator API. - -Version 0.1.0 builds for two ESP32-S3 board profiles, both with 8 MB flash and -8 MB PSRAM: the N8R8 module (Octal SPI PSRAM, CH343P USB-UART bridge; the -default) and the Unexpected Maker TinyS3[D] (Quad SPI PSRAM, native USB -Serial/JTAG, onboard/U.FL RF switch). Browser and CLI clients use the same -versioned HTTP/JSON API. DUT events identify workload boundaries; all -voltage/current evidence remains owned by external instruments. +Every image is one board profile plus one +[experiment profile](docs/EXPERIMENT_PROFILES.md). The default `baseline` +profile contains no heater or fan GPIOs and exposes no actuator API. The +[`fan-characterization` profile](docs/FAN_CHARACTERIZATION.md) adds a single +stimulus workload, `FAN_PWM_HOLD`, that drives only the gate of an external +open-drain stage and counts tach edges. That is characterization equipment, +not product fan control: no profile has a heater path, a closed loop, +thresholds, or fan-safety policy, and `heater_capability` and +`fan_control_capability` are `false` in every image. `/api/v1/device` reports +the board profile, experiment profile, build revision, device identity, and +where each measured quantity comes from. + +Version 0.1.0 builds for three ESP32-S3 board profiles, all with 8 MB PSRAM: +the N8R8 module (8 MB flash, Octal SPI PSRAM, CH343P USB-UART bridge; the +default), the N16R8 module on a Lonely Binary carrier (16 MB flash, Octal SPI +PSRAM, onboard RGB LED on GPIO48), and the Unexpected Maker TinyS3[D] (8 MB +flash, Quad SPI PSRAM, native USB Serial/JTAG, onboard/U.FL RF switch). +Each board's onboard RGB LED is DragonBench's status light: green when ready +and idle, off at boot and throughout every run, and never available to +fixture wiring. Browser and CLI clients use the same versioned HTTP/JSON API. +DUT events identify workload boundaries; all voltage/current evidence remains +owned by external instruments. ## Quick start @@ -42,6 +56,15 @@ idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.tinys3d" set idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.tinys3d" build ``` +For the N16R8 module, layer its overlay the same way. It keeps the shared +partition layout, which ends below 8 MB, so the upper half of its 16 MB flash +is unused: + +```text +idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.n16r8" set-target esp32s3 +idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.n16r8" build +``` + Delete a generated `sdkconfig` before switching profiles; existing values take precedence over the defaults files. The TinyS3[D] has no auto-reset circuit on its native USB port: hold BOOT, tap RESET, release BOOT, then flash with @@ -83,6 +106,7 @@ python -m cli.dragonbench traffic-peer --port 5001 --mode echo --duration 60 ``` See [bench workflow](docs/BENCH_WORKFLOW.md), [target profile](docs/TARGET_ESP32S3.md), +[experiment profiles](docs/EXPERIMENT_PROFILES.md), [fan characterization](docs/FAN_CHARACTERIZATION.md), and [protocol contract](protocol/openapi.yaml). ## Validation status @@ -109,6 +133,19 @@ yet been exercised on hardware, and workload execution and electrical characterization remain unvalidated there too. See [TinyS3[D] profile](docs/TARGET_TINYS3D.md). +On one N16R8 board, the baseline image's flash, boot, the 8 MB Octal PSRAM +test, the status LED, flashing through both USB-C connectors, the direct access +point, mDNS and the read-only API have been validated. Station mode, workload +execution and electrical characterization remain unvalidated there. See +[N16R8 profile](docs/TARGET_N16R8.md). + +The `fan-characterization` experiment profile builds with ESP-IDF 5.3.5 once +its wiring is stated, and refuses to build without it. Its stimulus lifecycle +and tach arithmetic pass host tests. Its fixture wiring is provisional. On the +N16R8, with that wiring and nothing attached, the image boots and holds the +gate pin at the expected static levels for 0% and 100% sink duty. The fixture, +fan, tach, PWM waveform and PPR remain unvalidated on hardware. + ## Scope rule A workload belongs here only when it answers a concrete electrical diff --git a/ci/build-esp32s3.sh b/ci/build-esp32s3.sh index 5af287f..409ad42 100644 --- a/ci/build-esp32s3.sh +++ b/ci/build-esp32s3.sh @@ -1,25 +1,92 @@ #!/usr/bin/env bash set -euo pipefail -profile="${1:-n8r8}" -case "$profile" in +# Usage: ci/build-esp32s3.sh [experiment] [--expect-refusal] +# board: n8r8 | n16r8 | tinys3d +# experiment: baseline (default) | fan-characterization +# +# An image is one board profile plus one experiment profile. Each experiment +# layers sdkconfig.defaults.; one that needs fixture wiring also +# layers the untracked sdkconfig..local. --expect-refusal builds +# the experiment WITHOUT its local overlay and passes only if the build stops +# naming every missing wiring setting. +board="${1:-n8r8}" +experiment="${2:-baseline}" +expect_refusal="${3:-}" +case "$board" in n8r8) defaults="sdkconfig.defaults" - expect=("CONFIG_SPIRAM_MODE_OCT 1" 'CONFIG_DB_TARGET_NAME "esp32s3-n8r8"' "CONFIG_DB_RF_SWITCH_GPIO -1") + expect=("CONFIG_SPIRAM_MODE_OCT 1" "CONFIG_ESPTOOLPY_FLASHSIZE_8MB 1" 'CONFIG_DB_TARGET_NAME "esp32s3-n8r8"' + "CONFIG_DB_RF_SWITCH_GPIO -1" "CONFIG_DB_STATUS_RGB_GPIO 48" "CONFIG_DB_STATUS_RGB_POWER_GPIO -1") + ;; + n16r8) + defaults="sdkconfig.defaults;sdkconfig.defaults.n16r8" + expect=("CONFIG_SPIRAM_MODE_OCT 1" "CONFIG_ESPTOOLPY_FLASHSIZE_16MB 1" 'CONFIG_DB_TARGET_NAME "esp32s3-n16r8"' + "CONFIG_DB_RF_SWITCH_GPIO -1" "CONFIG_DB_STATUS_RGB_GPIO 48" "CONFIG_DB_STATUS_RGB_POWER_GPIO -1") ;; tinys3d) defaults="sdkconfig.defaults;sdkconfig.defaults.tinys3d" - expect=("CONFIG_SPIRAM_MODE_QUAD 1" 'CONFIG_DB_TARGET_NAME "esp32s3-tinys3d"' "CONFIG_DB_RF_SWITCH_GPIO 38") + expect=("CONFIG_SPIRAM_MODE_QUAD 1" "CONFIG_ESPTOOLPY_FLASHSIZE_8MB 1" 'CONFIG_DB_TARGET_NAME "esp32s3-tinys3d"' + "CONFIG_DB_RF_SWITCH_GPIO 38" "CONFIG_DB_STATUS_RGB_GPIO 18" "CONFIG_DB_STATUS_RGB_POWER_GPIO 17" + "CONFIG_DB_STATUS_RGB_POWER_ACTIVE_LEVEL 1") + ;; + *) + echo "unknown board profile: $board (expected n8r8, n16r8, or tinys3d)" >&2 + exit 2 + ;; +esac + +# Non-baseline experiments, and what must be absent from every other image. +all_experiments=(fan-characterization) +declare -A experiment_symbol=([fan-characterization]="FAN_CHARACTERIZATION") +declare -A experiment_code=([fan-characterization]="ledc_|pcnt_|fan_characterization_") +declare -A experiment_entry=([fan-characterization]="fan_characterization_hold") +declare -A experiment_wiring=([fan-characterization]="CONFIG_DB_FAN_PWM_GATE_GPIO CONFIG_DB_FAN_GATE_SINK_LEVEL CONFIG_DB_FAN_TACH_GPIO") + +case "$experiment" in + baseline) + expect+=("CONFIG_DB_EXPERIMENT_BASELINE 1") + ;; + fan-characterization) + defaults="$defaults;sdkconfig.defaults.$experiment" + expect+=("CONFIG_DB_EXPERIMENT_${experiment_symbol[$experiment]} 1") + if [[ -z "$expect_refusal" ]]; then + if [[ ! -f "sdkconfig.$experiment.local" ]]; then + echo "$experiment needs sdkconfig.$experiment.local stating the wired fixture (docs/FAN_CHARACTERIZATION.md)" >&2 + exit 2 + fi + defaults="$defaults;sdkconfig.$experiment.local" + fi ;; *) - echo "unknown board profile: $profile (expected n8r8 or tinys3d)" >&2 + echo "unknown experiment profile: $experiment (expected baseline or ${all_experiments[*]})" >&2 exit 2 ;; esac +if [[ -n "$expect_refusal" && ( "$expect_refusal" != --expect-refusal || "$experiment" == baseline ) ]]; then + echo "--expect-refusal applies only to an experiment that needs fixture wiring" >&2 + exit 2 +fi rm -f sdkconfig idf.py -D SDKCONFIG_DEFAULTS="$defaults" fullclean idf.py -D SDKCONFIG_DEFAULTS="$defaults" set-target esp32s3 + +if [[ -n "$expect_refusal" ]]; then + if idf.py -D SDKCONFIG_DEFAULTS="$defaults" build > idf-build.log 2>&1; then + echo "unconfigured $experiment build succeeded; it must refuse to build" >&2 + exit 1 + fi + for setting in ${experiment_wiring[$experiment]}; do + if ! grep -q "#error \"$experiment: set $setting" idf-build.log; then + echo "unconfigured $experiment build did not name $setting" >&2 + exit 1 + fi + done + echo "$board/$experiment without wiring refused as expected" + exit 0 +fi + idf.py -D SDKCONFIG_DEFAULTS="$defaults" build 2>&1 | tee idf-build.log if grep -Ei 'warning:|error:' idf-build.log; then @@ -29,8 +96,35 @@ fi for line in "${expect[@]}"; do if ! grep -qF "#define $line" build/config/sdkconfig.h; then - echo "profile $profile did not produce '#define $line'" >&2 + echo "$board/$experiment did not produce '#define $line'" >&2 + exit 1 + fi +done + +nm_tool="${NM:-xtensa-esp32s3-elf-nm}" +# Fail rather than pass vacuously if the symbol table cannot be read. +symbols="$("$nm_tool" build/dragonbench.elf)" +if ! grep -q ' app_main$' <<< "$symbols"; then + echo "could not read symbols from build/dragonbench.elf with $nm_tool" >&2 + exit 1 +fi +for other in "${all_experiments[@]}"; do + code="$(grep -E " (${experiment_code[$other]})" <<< "$symbols" || true)" + if [[ "$other" == "$experiment" ]]; then + if ! grep -q " ${experiment_entry[$other]}$" <<< "$code"; then + echo "$board/$experiment does not link ${experiment_entry[$other]}" >&2 + exit 1 + fi + continue + fi + if grep -q "CONFIG_DB_EXPERIMENT_${experiment_symbol[$other]}" build/config/sdkconfig.h; then + echo "$board/$experiment also selected $other" >&2 + exit 1 + fi + if [[ -n "$code" ]]; then + echo "$board/$experiment links $other code:" >&2 + echo "$code" >&2 exit 1 fi done -echo "profile $profile verified" +echo "$board/$experiment verified" diff --git a/cli/dragonbench/main.py b/cli/dragonbench/main.py index 4cc74a5..b6e407b 100644 --- a/cli/dragonbench/main.py +++ b/cli/dragonbench/main.py @@ -17,6 +17,13 @@ class ClientError(RuntimeError): pass +# Mirrors firmware/common/include/db_fan.h; the firmware remains the authority. +FAN_PWM_HZ_MIN = 10 +FAN_PWM_HZ_MAX = 50000 +MAX_DURATION_MS = 3_600_000 +SWEEP_ORDERS = ("up", "down", "up-down", "down-up") + + class Client: def __init__(self, host: str, timeout: float = 10.0): self.base = host if "://" in host else f"http://{host}" @@ -37,6 +44,13 @@ def request(self, method: str, path: str, body: dict[str, Any] | None = None) -> if response.headers.get_content_type() == "application/x-ndjson": return [json.loads(line) for line in raw.splitlines() if line] return json.loads(raw) if raw else {} + except urllib.error.HTTPError as exc: + # Validation and conflict responses carry {"error": ...}; keep it. + try: + detail = json.loads(exc.read().decode()).get("error") + except (ValueError, AttributeError, OSError): + detail = None + raise ClientError(f"HTTP {exc.code}: {detail}" if detail else str(exc)) from exc except (urllib.error.URLError, json.JSONDecodeError) as exc: raise ClientError(str(exc)) from exc @@ -57,6 +71,8 @@ def parser() -> argparse.ArgumentParser: run.add_argument("--target-host") run.add_argument("--port", type=int) run.add_argument("--rate", type=int, default=0) + run.add_argument("--pwm-hz", type=int, help="FAN_PWM_HOLD only") + run.add_argument("--sink-duty-pct", type=float, help="FAN_PWM_HOLD only") abort = sub.add_parser("abort") abort.add_argument("run_id") sub.add_parser("events") @@ -68,9 +84,161 @@ def parser() -> argparse.ArgumentParser: traffic.add_argument("--port", type=int, required=True) traffic.add_argument("--mode", choices=("sink", "source", "echo"), required=True) traffic.add_argument("--duration", type=float, default=60.0) + sweep = sub.add_parser( + "fan-sweep", + help="run one FAN_PWM_HOLD per sink-duty step (fan-characterization profile)", + description="Every step is a separate run; the fixture releases the fan PWM line " + "between steps. No parameter has a default fan meaning.", + ) + sweep.add_argument("--pwm-hz", type=int, required=True) + sweep.add_argument("--low-pct", type=float, required=True, help="lowest sink duty, 0..100") + sweep.add_argument("--high-pct", type=float, required=True, help="highest sink duty, 0..100") + sweep.add_argument("--step-pct", type=float, required=True, help="sink duty step, 0.1 resolution") + sweep.add_argument("--order", choices=SWEEP_ORDERS, required=True) + sweep.add_argument("--hold-ms", type=int, required=True, help="FAN_PWM_HOLD duration per step") + sweep.add_argument("--gap-ms", type=int, default=0, help="host pause between steps (line released)") + sweep.add_argument("--output", type=Path, required=True, help="JSON results file, rewritten after every step") return p +def duty_tenths(value: float, name: str) -> int: + """Sink duty in tenths of a percent, rejecting anything the firmware would.""" + if not 0 <= value <= 100: # also rejects NaN + raise ClientError(f"{name} must be 0..100 in 0.1 steps") + tenths = round(value * 10) + if abs(value * 10 - tenths) > 1e-6: + raise ClientError(f"{name} must be 0..100 in 0.1 steps") + return tenths + + +def sweep_steps(low_tenths: int, high_tenths: int, step_tenths: int, order: str) -> list[tuple[str, int]]: + """Ordered (direction, sink duty tenths) steps. The far endpoint is always + included, and an up-down/down-up turnaround point is held once.""" + if step_tenths <= 0: + raise ClientError("step-pct must be greater than 0") + if low_tenths > high_tenths: + raise ClientError("low-pct must not exceed high-pct") + up = list(range(low_tenths, high_tenths + 1, step_tenths)) + if up[-1] != high_tenths: + up.append(high_tenths) + down = up[::-1] + if order == "up": + return [("up", d) for d in up] + if order == "down": + return [("down", d) for d in down] + if order == "up-down": + return [("up", d) for d in up] + [("down", d) for d in down[1:]] + if order == "down-up": + return [("down", d) for d in down] + [("up", d) for d in up[1:]] + raise ClientError(f"order must be one of {', '.join(SWEEP_ORDERS)}") + + +def _utc_now() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +def _write_json(path: Path, value: Any) -> None: + tmp = path.with_name(path.name + ".tmp") + tmp.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + tmp.replace(path) + + +def fan_sweep(args: argparse.Namespace, client: Client, sleep=time.sleep, clock=time.monotonic) -> dict[str, Any]: + if not FAN_PWM_HZ_MIN <= args.pwm_hz <= FAN_PWM_HZ_MAX: + raise ClientError(f"pwm-hz must be {FAN_PWM_HZ_MIN}..{FAN_PWM_HZ_MAX}") + if not 1 <= args.hold_ms <= MAX_DURATION_MS: + raise ClientError(f"hold-ms must be 1..{MAX_DURATION_MS}") + if args.gap_ms < 0: + raise ClientError("gap-ms must not be negative") + steps = sweep_steps(duty_tenths(args.low_pct, "low-pct"), duty_tenths(args.high_pct, "high-pct"), + duty_tenths(args.step_pct, "step-pct"), args.order) + + device = client.request("GET", "/api/v1/device") + if device.get("experiment_profile") != "fan-characterization": + raise ClientError(f"device experiment profile is {device.get('experiment_profile')!r}, " + "not 'fan-characterization'; FAN_PWM_HOLD is unavailable") + + report: dict[str, Any] = { + "schema": "dragonbench.fan_sweep/1", + "source": "dut_reported", + "status": "running", + "started_utc": _utc_now(), + "device": device, + "sweep": { + "pwm_hz": args.pwm_hz, "order": args.order, "low_pct": args.low_pct, + "high_pct": args.high_pct, "step_pct": args.step_pct, "hold_ms": args.hold_ms, + "gap_ms": args.gap_ms, + "planned_sink_duty_pct": [tenths / 10 for _, tenths in steps], + }, + "notes": "Each step is an independent FAN_PWM_HOLD run. The fan PWM line is released " + "between steps, so a step does not continue from the previous one. RPM is " + "present only when the device reports a configured PPR.", + "steps": [], + "external_measurements": None, + } + _write_json(args.output, report) + active_run = None + try: + for index, (direction, tenths) in enumerate(steps): + if index and args.gap_ms: + sleep(args.gap_ms / 1000) + body = {"workload": "FAN_PWM_HOLD", "duration_ms": args.hold_ms, + "pwm_hz": args.pwm_hz, "sink_duty_pct": tenths / 10} + host_started = _utc_now() + started = client.request("POST", "/api/v1/runs", body) + active_run = started.get("run_id") + if not active_run: + raise ClientError(f"step {index}: device refused the run: {started.get('error', started)}") + deadline = clock() + args.hold_ms / 1000 + 10.0 + while True: + run = client.request("GET", f"/api/v1/runs/{active_run}") + if run.get("state") == "complete": + break + if clock() > deadline: + raise ClientError(f"step {index}: run {active_run} did not complete in time") + sleep(0.2) + run_id, active_run = active_run, None + events = [e for e in client.request("GET", "/api/v1/events") if e.get("run_id") == run_id] + by_kind = {e.get("event"): e for e in events} + phase_start, phase_end = by_kind.get("phase_start", {}), by_kind.get("phase_end", {}) + report["steps"].append({ + "index": index, + "direction": direction, + "sink_duty_pct": tenths / 10, + "run_id": run_id, + "result": run.get("result"), + "host_started_utc": host_started, + "host_ended_utc": _utc_now(), + "phase_start_seq": phase_start.get("seq"), + "phase_start_uptime_ms": phase_start.get("uptime_ms"), + "phase_end_seq": phase_end.get("seq"), + "phase_end_uptime_ms": phase_end.get("uptime_ms"), + "parameters": phase_end.get("parameters"), + "metrics": phase_end.get("metrics"), + "fault": by_kind.get("fault"), + }) + _write_json(args.output, report) + if run.get("result") != "pass": + raise ClientError(f"step {index}: run {run_id} ended {run.get('result')}; sweep stopped") + except (ClientError, KeyboardInterrupt) as exc: + if active_run: + try: + client.request("POST", f"/api/v1/runs/{active_run}/abort", {}) + except ClientError: + pass + report["status"] = "interrupted" if isinstance(exc, KeyboardInterrupt) else "failed" + report["error"] = str(exc) or type(exc).__name__ + report["ended_utc"] = _utc_now() + _write_json(args.output, report) + if isinstance(exc, KeyboardInterrupt): + raise ClientError(f"sweep interrupted; partial results in {args.output}") from exc + raise + report["status"] = "complete" + report["ended_utc"] = _utc_now() + _write_json(args.output, report) + return {"written": str(args.output), "status": "complete", "steps": len(report["steps"])} + + def traffic_peer(bind_host: str, port: int, mode: str, duration: float) -> dict[str, Any]: if not 1 <= port <= 65535 or duration <= 0 or duration > 3600: raise ClientError("traffic peer requires port 1..65535 and duration >0..3600 seconds") @@ -106,6 +274,8 @@ def traffic_peer(bind_host: str, port: int, mode: str, duration: float) -> dict[ def execute(args: argparse.Namespace, client: Client) -> Any: if args.command == "traffic-peer": return traffic_peer(args.listen, args.port, args.mode, args.duration) + if args.command == "fan-sweep": + return fan_sweep(args, client) if args.command == "discover": try: addresses = sorted({item[4][0] for item in socket.getaddrinfo(args.host, 80)}) @@ -128,6 +298,10 @@ def execute(args: argparse.Namespace, client: Client) -> Any: body["host"] = args.target_host if args.port is not None: body["port"] = args.port + if args.pwm_hz is not None: + body["pwm_hz"] = args.pwm_hz + if args.sink_duty_pct is not None: + body["sink_duty_pct"] = duty_tenths(args.sink_duty_pct, "sink-duty-pct") / 10 return client.request("POST", "/api/v1/runs", body) if args.command == "abort": return client.request("POST", f"/api/v1/runs/{args.run_id}/abort", {}) @@ -142,6 +316,7 @@ def execute(args: argparse.Namespace, client: Client) -> Any: raise ClientError("device has no run to export") payload = { "source": "dut_reported", + "device": client.request("GET", "/api/v1/device"), "run": client.request("GET", f"/api/v1/runs/{run_id}"), "events": client.request("GET", "/api/v1/events"), "external_measurements": None, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index aa24cc7..dc93713 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,14 +1,25 @@ # Architecture -DragonBench has three deliberately narrow boundaries: +Each image is one board profile plus one experiment profile (see +[experiment profiles](EXPERIMENT_PROFILES.md)). Within an image, DragonBench +has three deliberately narrow boundaries: - `firmware/common`: workload names, run lifecycle, sequencing, and capability - semantics. It knows nothing about GPIOs or ESP-IDF drivers. + semantics. It knows nothing about GPIOs or ESP-IDF drivers. `db_experiment.h` + is the single place that reads the experiment-profile choice. `db_fan.c` + holds the fan-characterization stimulus lifecycle, gate-polarity mapping, and + tach arithmetic behind a small pad-level seam, so release-on-every-path and + both stage polarities are host-tested. - `firmware/targets/esp32s3`: direct ESP-IDF implementations for Wi-Fi, mDNS, HTTP, reset reason, SoC temperature, NVS, flash, inactive-OTA writes, reboot, - and TCP traffic. No other target backend exists. + and TCP traffic. No other target backend exists. `fan_characterization.c` + (static gate drive, LEDC stimulus, PCNT tach) compiles only into the + fan-characterization experiment profile; see + [fan characterization](FAN_CHARACTERIZATION.md). `build_provenance.cmake` + records the source revision on every build. - `cli`, `web`, and `protocol`: clients and the stable public contract. They do - not have privileged behavior paths. + not have privileged behavior paths. Multi-step orchestration, such as the + fan duty sweep, lives on the host; firmware runs one primitive at a time. There is intentionally no universal embedded HAL. A future target may implement the protocol and workload semantics directly when a concrete need exists. diff --git a/docs/BENCH_WORKFLOW.md b/docs/BENCH_WORKFLOW.md index 3244874..c750427 100644 --- a/docs/BENCH_WORKFLOW.md +++ b/docs/BENCH_WORKFLOW.md @@ -11,11 +11,28 @@ 5. Export the run and external measurements together, keeping their provenance distinct. +The board's status RGB LED is solid green while the DUT is ready and idle and +is turned off before a run's `phase_start`, so it is dark for the whole +`phase_start`–`phase_end` window (and unpowered on the TinyS3[D]). It turns +green again only after a run passes. Green outside a run is load on the +module supply: do not use readings from between runs as baseline. See the +target profiles for each board's LED. + The host/operator owns voltage sweeps. Firmware contains no voltage thresholds or electrical pass/fail limits. `OTA_PARTITION_WRITE` erases, writes, and verifies only an inactive OTA slot; it never selects that slot for boot. Controlled reboot is single-shot and never forms a reboot loop. +Record `/api/v1/device` with every capture: it names the board profile, +experiment profile, build revision, and device, and its +`measurement_provenance` says which quantities DragonBench measures and which +the bench must supply. + +On a `fan-characterization` image, `FAN_PWM_HOLD` and the host `fan-sweep` +command provide the stimulus and raw tach counts. Wiring, semantics, and limits +are in [fan characterization](FAN_CHARACTERIZATION.md). Fan supply current, PWM-line and tach waveforms, +optical RPM, temperature, and airflow remain external evidence. + Recommended external fields are PSU/input voltage, `+5V_SYS_GATE`, `+5V_MCU_FEED`, `+5V_MCU`, average/peak current, minimum rail voltage, reset or brownout occurrence, and Wi-Fi disconnect/reconnect. diff --git a/docs/EXPERIMENT_PROFILES.md b/docs/EXPERIMENT_PROFILES.md new file mode 100644 index 0000000..7a1d938 --- /dev/null +++ b/docs/EXPERIMENT_PROFILES.md @@ -0,0 +1,73 @@ +# Experiment profiles + +DragonBench is a characterization platform that grows one experiment at a time. +Every image is exactly: + +- one **board profile**, the module or board it runs on + (`CONFIG_DB_TARGET_NAME`: `esp32s3-n8r8`, `esp32s3-n16r8`, `esp32s3-tinys3d`), plus +- one **experiment profile**, the narrow characterization setup it carries + (the `DB_EXPERIMENT_PROFILE` Kconfig choice). + +An experiment profile is not a feature flag. Profiles are mutually exclusive, +each is reviewed as a whole, and each brings only the stimulus or acquisition +code, fixture settings, and workloads it needs. Two experiments are never +combined in one image. If a setup needs both, it is a new, separately reviewed +profile. + +| Profile | Overlay | Adds | Status | +|---|---|---|---| +| `baseline` | none (explicit in `sdkconfig.defaults`) | ESP32-S3 module workloads only; no fixture I/O | Default | +| `fan-characterization` | `sdkconfig.defaults.fan-characterization` + untracked `sdkconfig.fan-characterization.local` | `FAN_PWM_HOLD`: bench PWM stimulus through an external open-drain stage, PCNT tach capture. Not product fan control. See [fan characterization](FAN_CHARACTERIZATION.md). | Build-verified, not hardware-validated | + +Profiles such as thermistor or heater characterization may follow; none exists +yet. + +## Building + +Layer the board overlay, then at most one experiment overlay, then that +experiment's local wiring overlay if it has one: + +```text +idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.;sdkconfig.defaults.;sdkconfig..local" build +bash ci/build-esp32s3.sh [experiment] [--expect-refusal] +``` + +Delete a generated `sdkconfig` before switching profiles. + +## What every profile reports + +`/api/v1/device` names the image so artifacts never need reverse-engineering: + +- `board_profile` and `experiment_profile` +- `build`: `firmware_version`, `git_sha`, `source_tree` (`clean`, `dirty`, or + `unknown`), and `esp_idf`. The revision is recorded on every build by + `build_provenance.cmake`. It is `null`/`unknown` whenever git could not + establish it; nothing is guessed. +- `device_id` +- `measurement_provenance`: each quantity of interest mapped to + `dut.` (measured by this firmware), `derived.` (computed + from DUT data), or `external` (bench instruments; DragonBench reports no + value) + +The boot event carries board profile, experiment profile, and revision, so a +serial log alone identifies the image. `heater_capability` and +`fan_control_capability` are `false` in every profile. + +## Rules for a new profile + +1. Add one entry to the `DB_EXPERIMENT_PROFILE` choice, with fixture options in + an `if DB_EXPERIMENT_` block. Wiring has no defaults and an + unconfigured build fails with an `#error` per missing setting. +2. Add its name to `firmware/common/include/db_experiment.h`. That is the only + file that reads `CONFIG_DB_EXPERIMENT_*`; code uses `DB_EXPERIMENT_`. +3. Add `sdkconfig.defaults.` that selects the profile and nothing else. +4. Compile its code only under `DB_EXPERIMENT_`, and put testable logic in + `firmware/common` behind a hardware seam, with native tests. +5. Add it to `ci/build-esp32s3.sh` (`all_experiments`, the symbols that must be + absent elsewhere, and its wiring settings) and to CI. +6. Extend `measurement_provenance` with exactly what it measures, derives, or + relies on externally. +7. Document it, and keep it free of product policy: no product thresholds, + timeouts, or production pin assignments. + +`tests/test_experiment_profiles.py` enforces rules 1–3 and 5. diff --git a/docs/FAN_CHARACTERIZATION.md b/docs/FAN_CHARACTERIZATION.md new file mode 100644 index 0000000..b767b0f --- /dev/null +++ b/docs/FAN_CHARACTERIZATION.md @@ -0,0 +1,292 @@ +# Fan-characterization experiment profile + +> **BENCH CHARACTERIZATION EQUIPMENT — NOT PRODUCT FAN CONTROL.** This +> experiment profile drives only the gate of an external open-drain stage and +> counts tach edges. +> It has no closed loop, no thresholds, no heater, and no fan-safety policy. +> `heater_capability` and `fan_control_capability` remain `false`. + +`fan-characterization` is one of DragonBench's +[experiment profiles](EXPERIMENT_PROFILES.md): an image built with it reports +`"experiment_profile": "fan-characterization"`. + +Status: firmware, API, and host tooling are implemented and build-verified. +The image has booted on an N16R8 with nothing attached to the gate or tach +pins, and a DMM confirmed the static 0% and 100% gate levels there (see +[TARGET_N16R8.md](TARGET_N16R8.md#validation-status)). The current provisional +Rev 0 wiring is the N16R8 with GPIO6 as the gate and GPIO7 as the conditioned +tach input, see +[TARGET_N16R8.md](TARGET_N16R8.md#fan-characterization-fixture-pins). These are +bench-fixture assignments, not JumpJet product GPIOs. No complete fixture has +been built or connected; the rest is listed under [Open items](#open-items). + +The first specimen is the JumpJet Sanyo Denki `9GA0424P3J001` candidate. Its +plan and evidence record are JumpJet's +`docs/hardware/9ga0424p3j001-characterization.md`. DragonBench supplies a +stimulus and raw tach counts for that plan. It defines no JumpJet value: no +minimum duty, RPM proof threshold, fan-proof timeout, stall threshold, PWM +frequency, PPR, or production GPIO. + +## What it adds + +| Piece | Where | +|---|---| +| `DB_EXPERIMENT_FAN_CHARACTERIZATION` choice entry and wiring options | `Kconfig.projbuild`, `sdkconfig.defaults.fan-characterization` | +| `FAN_PWM_HOLD` workload | `firmware/common/db_run.c` (registry, validation) | +| Stimulus lifecycle and tach arithmetic, host-tested | `firmware/common/db_fan.c` | +| Static gate drive, LEDC stimulus, and PCNT tach capture | `firmware/targets/esp32s3/main/fan_characterization.c` | +| `fan-sweep` host command | `cli/dragonbench/main.py` | + +Every other profile, including `baseline`, reports `FAN_PWM_HOLD` as +`unsupported` (reason `requires fan-characterization experiment profile`), +rejects it with HTTP 400, and links no LEDC, PCNT, or fan-characterization +code. CI checks that absence in the ELF. + +## Electrical model + +```text + DragonBench GPIO ── gate/base ─┐ fan PWM lead (brown, per the San Ace + (CONFIG_DB_FAN_PWM_GATE_GPIO) │ convention; verify on the specimen) + ┌┴┐ │ + gate resistor to release level│ │ external │ + └┬┘ open-drain ─┘ drain/collector + │ stage source/emitter ── fan GND + = bench GND + fan tach lead (yellow, verify) ── external pull-up to 3.3 V ── conditioning ── + DragonBench GPIO (CONFIG_DB_FAN_TACH_GPIO) +``` + +- **The firmware drives only the transistor gate.** No DragonBench pin connects + to a fan lead. +- **`sink_duty_pct`** is the fraction of time the external stage conducts and + pulls the fan PWM line low. It is an electrical quantity. How the fan responds + to it, including at 0 % and 100 %, is what the bench is there to measure. +- **The endpoints are static, not PWM.** 0 % holds the gate at its release + level (`stimulus: static_release`) and 100 % holds it at its sink level + (`stimulus: static_sink`), as plain GPIO outputs. Only values strictly + between use LEDC (`stimulus: pwm`), so the LEDC compare value is always + 1..2^bits−1. On ESP32-S3, a duty of 2^duty_resolution can overflow the LEDC + duty counter (ESP-IDF 5.3.5 LEDC documentation), so it is never programmed. +- **Released** means the gate is at its release level, so the stage is off and + the fan PWM line is left to the fan's own input circuit. The firmware makes no + claim about fan speed when the line is released. +- `CONFIG_DB_FAN_GATE_SINK_LEVEL` states the gate level that makes the stage + sink: `1` for a non-inverting stage (N-channel MOSFET or NPN), `0` for an + inverting one. The opposite level is the release level. An inverting stage + inverts the pad, not the duty. + +### Released on every path + +| Moment | Mechanism | +|---|---| +| Power-up, reset, flashing, panic, brownout | The GPIO is an input with no firmware drive. The external gate resistor must hold the stage off. The firmware never enables an internal pull on the gate. | +| Boot | `fan_characterization_boot()` is the first statement of `app_main`. It drives the release level and reads the pad back. | +| Run start | `db_fan_hold` releases and verifies the line, then confirms tach capture is ready, before any actuation. If either fails, nothing is actuated. | +| Run end, abort, and every error | `db_fan_hold` has one exit path, which releases and verifies the line. | +| `esp_restart` (including `CONTROLLED_REBOOT`) | Shutdown handler releases the line. | + +Every static drive (release or 100 % sink) writes the target level to the +output register first, then moves the pad from LEDC to a plain GPIO output, so +the switch cannot glitch to the other level. Which gate level sinks is decided +in the platform-neutral layer from `CONFIG_DB_FAN_GATE_SINK_LEVEL`, where native +tests cover both polarities at 0 %, PWM, and 100 %. Verification reads back the **pad** level. It does not +observe the transistor or the fan line; confirm those on the scope. + +A release that cannot be verified is reported as `"released": false` with +`fixture_error`, a `fault` event, and a `fail` result. `/api/v1/status` shows +`bench_stimulus.fan_fixture.line_state` as `release_failed`. + +## Configuration + +Every fixture setting defaults to unset. An overlay without wiring stops the +build with one `#error` per missing setting. A reserved or conflicting pin also +fails the build: strapping pins (0, 3, 45, 46), USB (19, 20), flash/PSRAM +(26–32), Octal PSRAM data pins on octal profiles (33–37), the UART0 console +(43, 44), `CONFIG_DB_RF_SWITCH_GPIO`, the board's status RGB LED pins +(`CONFIG_DB_STATUS_RGB_GPIO`: 48 on N8R8 and N16R8, 18 on TinyS3[D]; +`CONFIG_DB_STATUS_RGB_POWER_GPIO`: 17 on TinyS3[D]), or the gate and tach +sharing one pin. Those rules only exclude pins known to be taken; they do not +choose one. + +Create an untracked `sdkconfig.fan-characterization.local` (ignored by Git) +stating the wired fixture: + +```text +CONFIG_DB_FAN_PWM_GATE_GPIO= +CONFIG_DB_FAN_GATE_SINK_LEVEL=<0|1> +CONFIG_DB_FAN_TACH_GPIO= +# Optional: +# CONFIG_DB_FAN_TACH_INTERNAL_PULLUP=y # default off: use an external pull-up +# CONFIG_DB_FAN_TACH_GLITCH_FILTER_NS=<0..12700> +# CONFIG_DB_FAN_TACH_PPR=<1..16> # only once PPR is established +# CONFIG_DB_FAN_TACH_PPR_EVIDENCE="" +``` + +`CONFIG_DB_FAN_TACH_PPR` stays `0` (unknown) until independent evidence, such +as an optical tachometer compared against tach edges, establishes it. A non-zero +PPR without `CONFIG_DB_FAN_TACH_PPR_EVIDENCE` fails the build. With PPR unknown, +events carry `"ppr": null, "rpm": null` and RPM is never derived. + +Build: board overlay, then experiment overlay, then local wiring: + +```text +idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.tinys3d;sdkconfig.defaults.fan-characterization;sdkconfig.fan-characterization.local" build +# or, with the same checks CI uses: +bash ci/build-esp32s3.sh tinys3d fan-characterization +``` + +Delete a generated `sdkconfig` before switching profiles. At boot the serial log +prints a `FAN-CHARACTERIZATION EXPERIMENT PROFILE` warning with the configured +pins and line state. + +## FAN_PWM_HOLD + +```text +POST /api/v1/runs +{"workload": "FAN_PWM_HOLD", "pwm_hz": , "sink_duty_pct": , "duration_ms": } +``` + +| Parameter | Accepted | Notes | +|---|---|---| +| `pwm_hz` | integer 10–50000 | Limits of the fixture generator, not a fan specification. They keep at least 10-bit duty resolution from the 80 MHz LEDC clock. Required even at 0 % and 100 %, where no PWM is generated, so a sweep is uniform. | +| `sink_duty_pct` | 0–100 in 0.1 steps | 0 and 100 are exact static levels; values between are quantized to the LEDC resolution and the produced value is reported. | +| `duration_ms` | integer 1–3600000, required | No implied default for a stimulus. | + +All three are required and validated on the device. `pwm_hz` or +`sink_duty_pct` on any other workload is rejected. A hold runs as follows: + +1. Plan the stimulus (static release, PWM, or static sink). +2. Release the line and verify it. +3. Confirm tach capture is ready. Tach edges are the evidence this workload + exists for, so an unavailable tach refuses the run (`tach_unavailable`) + before anything is actuated. +4. Actuate the planned stimulus. +5. Clear the tach counter and open the window immediately after. +6. Hold, checking for abort every 20 ms. +7. Read the count and close the window. +8. Release, on this and every earlier exit. + +Only one run is active at a time. + +Tach capture uses PCNT on the tach GPIO. It counts falling edges only, so +counted edges equal tach pulses. An accumulating watch point extends the 16-bit +hardware counter. + +### Events + +The normal run lifecycle applies: `phase_start` → (`fault`) → `phase_end` → +`run_complete`, all with `run_id`, `seq`, and `uptime_ms` for correlation with +external captures and DragonSniff. `phase_start` is emitted before the stimulus +is applied, and `phase_end` after the line is released. + +`parameters` (both phase events): + +```json +{"pwm_hz":25000,"sink_duty_pct":35.5,"duration_ms":10000,"stimulus":"pwm","applied_sink_duty_pct":35.498,"duty_resolution_bits":11} +{"pwm_hz":25000,"sink_duty_pct":100.0,"duration_ms":10000,"stimulus":"static_sink","applied_sink_duty_pct":100.000} +``` + +`metrics` (`phase_end`, `run_complete`): + +```json +{"tach_edges":2001,"window_start_us":81234567,"window_us":10000412,"edge_hz":200.091,"ppr":null,"rpm":null,"released_us":91235101} +``` + +- `window_start_us`, `window_us`, `released_us`: `esp_timer` microseconds since + boot, the same clock as `uptime_ms`. The window opens after the stimulus is + applied and the counter cleared, and closes after the final count read. +- `edge_hz = tach_edges / window`, averaged over the whole hold, transients + included. It has ±1 edge of quantization. +- `ppr` and `rpm` are `null` unless PPR is configured with evidence. +- `fixture_error` appears for `invalid_parameters`, `pre_release_failed`, + `tach_unavailable`, `actuation_failed`, `tach_clear_failed`, + `tach_read_failed`, or `release_failed`. + +### Identity and provenance + +`/api/v1/device` reports `board_profile`, `experiment_profile`, `build` +(firmware version, git SHA, and source-tree state when the build could +establish them), and `device_id`. The boot event carries the same image +identity. `measurement_provenance` records that `fan_tach_edges` come from +`dut.esp32s3_pcnt`, that `fan_speed` is `external` unless PPR is configured +with evidence (then `derived.fan_tach_edges_and_configured_ppr`), and that fan +supply voltage and current, PWM-line waveform, airflow, and temperature are +`external`. + +## Host sweep + +`fan-sweep` runs one `FAN_PWM_HOLD` per step and writes machine-readable JSON. +Sweep logic lives only on the host. + +```text +python -m cli.dragonbench --host fan-sweep --pwm-hz \ + --low-pct --high-pct --step-pct \ + --order up|down|up-down|down-up --hold-ms [--gap-ms ] --output sweep.json +``` + +- Order: `up` goes low → high and `down` goes high → low. `up-down` and + `down-up` visit the turnaround point once. The far endpoint is always + included, even when the step does not divide the range evenly. +- The sweep refuses a device whose `/api/v1/device` does not report + `experiment_profile: fan-characterization`, before starting any run. +- The file is rewritten after every step. It records the device identity, the + planned steps, and for each step the run ID, result, host timestamps, + phase sequence numbers, and uptime, parameters, metrics, and any fault. + `external_measurements` is `null`: attach scope, current, and optical data + separately, with their own provenance. +- The sweep stops at the first step whose result is not `pass`. On a timeout, + refusal, or Ctrl-C it aborts the active run (which releases the line) and + writes `status: failed` or `interrupted` with the partial results. + +`run FAN_PWM_HOLD --duration --pwm-hz --sink-duty-pct ` starts a +single hold. + +## Known limitations + +- **The line is released between sweep steps.** Each step is an independent + run, so between steps the fan PWM line is released for the host round trip + (tens to hundreds of milliseconds) plus `--gap-ms`. A downward sweep + therefore does not hold a continuously running fan through decreasing duty. + Minimum stable running duty and restart hysteresis + (characterization plan §9) cannot be read from this sweep alone. Doing that + would need a firmware-held multi-step primitive, which this slice + deliberately leaves out. +- **Tach results are one whole-hold average.** There is no time series, first + edge timestamp, jitter, or startup-latency measurement (plan §7, §9). Use the + external logic analyzer or scope for those. Longer holds reduce how much of + the average is transient. +- **Tach semantics are unverified.** Counting assumes one falling edge per + tach pulse on a clean, conditioned signal. A noisy line needs the glitch + filter and a scope check. Stopped or locked-rotor tach behavior (plan §10) is + reported only as raw edges. +- Static gate drive has been checked on hardware with a DMM only, with no + stage attached. PCNT accumulation and pad readback have run only with the + tach pin unconnected. LEDC output and the shutdown handler have never run on + hardware. +- Nothing here measures supply current, temperature, airflow, noise, or + vibration (plan §11–13). Those stay with external instruments. + +## Open items + +These must come from the bench before any wiring or first power: + +1. Rev 0 provisionally uses the N16R8 with GPIO6 as the gate and GPIO7 as the + conditioned tach input (`CONFIG_DB_FAN_GATE_SINK_LEVEL=1`), per + [TARGET_N16R8.md](TARGET_N16R8.md#fan-characterization-fixture-pins). Still + open: checking GPIO6's reset-state pull in the ESP32-S3 datasheet IO MUX + table against the gate resistor, and confirming both pins once the stage + and tach conditioning exist. +2. Stage topology and part: MOSFET or BJT, inverting or not (sets + `CONFIG_DB_FAN_GATE_SINK_LEVEL`), gate/base resistor, and a gate resistor to + the release level sized to hold the stage off with the GPIO undriven. + Rev 0 intends a non-inverting N-channel MOSFET, which the provisional sink + level above assumes; the physical stage is not yet built. +3. Fan PWM-lead electrical limits and its internal pull-up (plan §6), and + confirmation that the stage's voltage and current ratings cover them. +4. Tach output circuit, sink-current limit, the external 3.3 V pull-up value, + and any series resistor or clamp that keeps the GPIO at or below 3.3 V + (plan §7). Whether the internal pull-up is ever acceptable. +5. Lead identity on the delivered specimen (plan §3) and the common-ground + arrangement between the 24 V supply and the ESP32-S3. +6. Glitch-filter setting after scoping the tach line, if one is needed. +7. PPR and the evidence establishing it, only after it has been measured. diff --git a/docs/TARGET_ESP32S3.md b/docs/TARGET_ESP32S3.md index af47336..59cd721 100644 --- a/docs/TARGET_ESP32S3.md +++ b/docs/TARGET_ESP32S3.md @@ -41,10 +41,11 @@ when one is configured through Kconfig or the `/setup` page. Accordingly, two-entry capacity. A lost station connection is retried three times immediately, then with backoff doubling from 5 s to a 60 s ceiling, indefinitely; each attempt scans channels and can briefly interrupt -access-point clients. This profile is the default; the TinyS3[D] is described in -[its own profile](TARGET_TINYS3D.md). Generated `sdkconfig` and -`managed_components/` remain local; the Component Manager lockfile is tracked -to preserve the dependency graph used by the validated build. +access-point clients. This profile is the default; the +[N16R8](TARGET_N16R8.md) and the [TinyS3[D]](TARGET_TINYS3D.md) have their own +profiles. Generated `sdkconfig` and `managed_components/` remain local; the +Component Manager lockfile is tracked to preserve the dependency graph used by +the validated build. Octal SPI PSRAM shares the SPI0/SPI1 clock domain with flash on ESP32-S3, so `sdkconfig.defaults` pins flash frequency to 80 MHz alongside @@ -62,6 +63,65 @@ watchdog, or brownout reset observed. On-device Wi-Fi/mDNS connectivity, API calls over Wi-Fi, workloads, and electrical characterization remain unvalidated on this module. +## Status RGB LED + +The N8R8 board is the original DragonBench board, a VCC-GND YD-ESP32-S3 +(confirmed by Dan, 2026-09-29; it is not a Lonely Binary board). Its published +schematic (YD-ESP32-S3-COREBOARD V1.4, 2022-09-23) includes the CH343P bridge +observed on this module. From that schematic: + +- LED: XL-5050RGBC-WS2812B, powered from the 5 V rail (not a GPIO) +- Data: GPIO48, through a 0 Ω 0603 link labelled `RGB` +- No power or enable GPIO + +Not yet checked on the physical board: whether the `RGB` link is fitted. If +it is open, the LED is disconnected and the status light simply stays dark. + +DragonBench owns this LED as its status light, so experiment fixture wiring +may not use its pins: the fan-characterization build fails if the gate or +tach is set to one. The firmware drives it through the RMT peripheral (not +LEDC or PCNT), one frame per change, with no task or timer. It is off from +boot until the `ready` event, then solid dim green (green channel 16 of 255) +while the device is ready and idle. Starting a run turns it off before the +run's `phase_start` event, and it stays off until that run has ended. It +turns green again only after a run passes; after a failed or aborted run, or +a passed `CONTROLLED_REBOOT`, it stays off until a later run passes. + +Off means dark. The LED stays powered from 5 V, so its idle current remains +part of the board's baseline load during measurement. Nothing about the LED +has been observed on hardware through this change. + +## Pin compatibility with the N16R8 + +The N8R8 (VCC-GND YD-ESP32-S3) and the [N16R8](TARGET_N16R8.md) (Lonely +Binary Gold Edition) are different carriers, but DragonBench deliberately +treats the pins it cares about identically on both. VCC-GND's pin table and +schematic V1.4, and Lonely Binary's GPIO map, agree on these: + +| GPIO | Both boards | DragonBench | +|---|---|---| +| 6, 7 | plain GPIO on the headers, nothing onboard | fan fixture gate and tach | +| 48 | onboard RGB LED data | status light; fixture wiring rejected | +| 35–37 | used by the Octal PSRAM, unavailable (33, 34 not broken out) | rejected | +| 19, 20 | native USB D-/D+ on its own USB-C connector | rejected | +| 43, 44 | UART0 TX/RX to the USB-UART bridge | rejected | +| 0, 3, 45, 46 | strapping | rejected | + +That is the only equivalence claimed. The carriers still differ, and each +target profile records its own: + +- flash: 8 MB here, 16 MB on the N16R8 +- USB-UART bridge: CH343P here; not identified on the N16R8 +- RGB LED: a 5 V WS2812B behind a 0 Ω `RGB` link here; on the N16R8 the part + and supply are unconfirmed, beside a solder pad labelled `RGB` +- YD-only parts on pins DragonBench already rejects: TX and RX LEDs on + GPIO43/44, a 10 kΩ pull-up to 3.3 V on GPIO3 through a `USB-JTAG` 0 Ω + link, the BOOT button and DTR/RTS auto-program transistors on GPIO0 and EN +- connector labels: `UART` and `USB` are Lonely Binary's; the YD's two USB-C + ports go to the CH343P and to native USB (GPIO19/20) + +## Partition layout + The 8 MB layout uses two 3 MB OTA app slots so the inactive-partition workload has a real target, plus a 1 MB scratch partition. There is no factory app partition. The exact layout is maintained in `partitions.csv`. diff --git a/docs/TARGET_N16R8.md b/docs/TARGET_N16R8.md new file mode 100644 index 0000000..eaeb4f0 --- /dev/null +++ b/docs/TARGET_N16R8.md @@ -0,0 +1,189 @@ +# ESP32-S3 N16R8 target profile + +> **Validation state: baseline image physically validated on one board +> (2026-09-29).** Flash, boot, PSRAM, the status LED, both USB-C paths, the +> access point, mDNS and the read-only API have been exercised. The +> `fan-characterization` image has been booted and its static gate levels +> checked with nothing attached. The fixture, fan, tach and PWM waveform have +> not been validated. See +> [Validation status](#validation-status). + +Board facts below come from visual inspection of the physical board and its +silkscreen, and from Lonely Binary's interactive GPIO map for its ESP32-S3 +Gold Edition board (screenshot supplied by Dan, 2026-09-29). Anything +exercised on the board is marked as such under +[Validation status](#validation-status). + +- Module: ESP32-S3-WROOM-1, marked `MCN16R8` +- Flash: 16 MB +- PSRAM: 8 MB, Octal SPI +- Carrier: Lonely Binary pluggable-terminal development board, with BOOT and + RESET buttons, two USB-C connectors, and an external USB-UART bridge +- USB-C connectors: labelled `UART` and `USB` on Lonely Binary's map (with + the antenna at the top, `UART` is on the left and `USB` on the right). + Observed on the board: the left connector enumerates as a USB-UART bridge + (VID:PID `1A86:7522`, which Windows' WCH driver labels CH340K; the chip + marking has not been inspected), and the right connector as the chip's + native USB Serial/JTAG (`303A:1001`) +- Onboard RGB LED: GPIO48 (silkscreen `RGB@IO48`); see + [Status RGB LED](#status-rgb-led) +- UART console: TX GPIO43, RX GPIO44 +- Native USB: D- GPIO19, D+ GPIO20 +- Convenience silkscreen labels: SPI SS GPIO10, MOSI GPIO11, SCK GPIO12, + MISO GPIO13; I2C SDA GPIO8, SCL GPIO9. These are labels only, not evidence + that the carrier wires anything to those pins. +- GPIO6 and GPIO7 are exposed on the pluggable terminals. Lonely Binary's + map shows both as plain GPIO. +- Lonely Binary's map marks these as restricted: TX/RX (43/44), GPIO0, 19, + 20, and 48; strapping pins 3, 45 and 46; and GPIO35–37 as unusable (Octal + PSRAM). GPIO33 and 34 are not broken out. DragonBench's fixture pin guards + already reject all of these on this board. +- RF switch: none +- The GPIOs DragonBench uses or rejects match the N8R8, a different carrier + (VCC-GND YD-ESP32-S3); see + [pin compatibility](TARGET_ESP32S3.md#pin-compatibility-with-the-n16r8) +- Profile overlay: `sdkconfig.defaults.n16r8`, reported target `esp32s3-n16r8` + +## Configuration + +The overlay layers on the shared `sdkconfig.defaults`, which already selects +Octal SPI PSRAM at 80 MHz, 80 MHz flash, no RF-switch GPIO, and the status +RGB LED on GPIO48 (`CONFIG_DB_STATUS_RGB_GPIO=48`, no power GPIO). It changes +only two things: + +- `CONFIG_ESPTOOLPY_FLASHSIZE_16MB` +- `CONFIG_DB_TARGET_NAME="esp32s3-n16r8"` + +```text +idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.n16r8" set-target esp32s3 +idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.n16r8" build +# or, with the same checks CI uses: +bash ci/build-esp32s3.sh n16r8 +``` + +Delete a generated `sdkconfig` before switching profiles. + +## Flash layout + +The physical flash is 16 MB, and the image header declares 16 MB. The +DragonBench partition layout (`partitions.csv`) is shared with the 8 MB boards +and intentionally ends below 8 MB (at `0x720000`). The upper 8 MB is unused. +The layout already holds the current firmware, both OTA slots, and the scratch +workload partition, so it has not been redesigned for this board. + +## Fan-characterization fixture pins + +The intended DragonBench Rev 0 fan-characterization fixture wiring on this +board is GPIO6 to the gate of an external non-inverting N-channel MOSFET stage +(`CONFIG_DB_FAN_GATE_SINK_LEVEL=1`: GPIO high turns the stage on, which pulls +the fan PWM line low) and GPIO7 for the conditioned tach input. These are +provisional DragonBench bench fixture assignments. They are not JumpJet GPIO +assignments, product requirements, or fan safety thresholds. Hosted CI compiles +the `fan-characterization` profile for this board with this wiring. PPR stays +`0` (unknown). + +On this board the fixture pin guards reject GPIO48 (status RGB LED) and the +Octal PSRAM pins GPIO33–37, along with the pins every board rejects: strapping +pins, native USB, the UART console, flash, nonexistent pins, and a gate that is +also the tach. + +## Status RGB LED + +Confirmed by visual inspection and by Lonely Binary's GPIO map: an onboard +RGB LED in a 5050-style package with its data on GPIO48 (silkscreen +`RGB@IO48`), which the map marks as a restricted pin. The map shows a +solder-pad pair labelled `RGB` directly beside the LED, and no power or enable +GPIO; none is configured. + +Not confirmed from a primary source: the LED part (the map does not name it), +its supply rail, and whether the `RGB` pad has to be bridged. The pad on the +validated board was not inspected, but its LED works (see +[Validation status](#validation-status)). +Secondary summaries of Lonely Binary's guide describe the LED as a WS2812 on +GPIO48 and say the `RGB` pads must be bridged to use it. DragonBench drives it +as a WS2812, the same as the N8R8. If the LED is not WS2812-compatible, or the +pad is open, the status light stays dark; it cannot affect other pins. + +DragonBench owns this LED as its status light, so experiment fixture wiring +may not use its pins: the fan-characterization build fails if the gate or +tach is set to one. The firmware drives it through the RMT peripheral (not +LEDC or PCNT), one frame per change, with no task or timer. It is off from +boot until the `ready` event, then solid dim green (green channel 16 of 255) +while the device is ready and idle. Starting a run turns it off before the +run's `phase_start` event, and it stays off until that run has ended. It +turns green again only after a run passes; after a failed or aborted run, or +a passed `CONTROLLED_REBOOT`, it stays off until a later run passes. + +Off means dark, not unpowered: without a power GPIO the LED's idle current +remains part of the board's baseline load. + +## Validation status + +- ESP-IDF 5.3.5 builds: `baseline`, and `fan-characterization` with the + wiring above. Both have been flashed to and booted on the board (below). + +The `baseline` image from commit `153c6d1` (ESP-IDF 5.3.5, clean tree) was +flashed to and exercised on one board on 2026-09-29. No fixture, fan, GPIO6/7 +wiring or 24 V supply was connected. Observed on that board: + +- Identity: the boot event and `/api/v1/device` report target and + `board_profile` `esp32s3-n16r8`, experiment profile `baseline`, and the + build's git SHA, clean source tree and ESP-IDF version. +- Flash: esptool detects 16 MB (flash ID `46`/`4018`); the bootloader reports + 16 MB in DIO mode at 80 MHz and loads the partition table above. +- PSRAM: ESP-IDF's Octal PSRAM driver identifies an AP Memory generation-3, + 64 Mbit (8 MB) device, and the boot-time memory test passes. The full 8 MB + is added to the heap at 80 MHz. Octal operation rests on that driver + succeeding; ESP-IDF does not print a separate bus-mode line. +- Boot: complete boot logs over the `UART` connector show no warnings, + panic, watchdog reset or brownout, and the device reaches `ready` about + 1.2 s after reset. RESET-button and bridge-RTS resets restart it to + `ready`; both report reset reason `power_on`, which the ROM also uses for a + cold power-on. +- `UART` connector: auto-download, flash writing with hash verification, + RTS reset and the UART0 console log all work through it. +- `USB` connector: auto-download and flash writing with hash verification + work. On a RESET press the port dropped out and re-enumerated, and boot + finished before the host reopened it, so no boot log was captured there; + use the `UART` connector for boot logs. Host-driven reset into the + application over this connector was not established. +- Status LED: the factory firmware lit it, and under DragonBench it was dark + after both a RESET press and a cold power-up, then green at `ready` + (visual, untimed). The dark gap after a warm reset was clearly shorter, + consistent with the LED holding its previous colour until `app_main` first + drives it off. The observer described the green as bright, although the + firmware sends 16 of 255. +- Network: the access point `DragonBench-` starts with WPA2 on + channel 1. The web UI and the read-only API endpoints (`/api/v1/device`, + `status`, `sensors`, `workloads`, `events`) answer, and the mDNS name + resolves and serves the API from a client on the access point. + +The `fan-characterization` image from commit `b90e713`, with the wiring +above, was then smoke-tested on the same board over the `UART` connector. +Nothing was attached except a DMM on GPIO6; GPIO7 was left unconnected, and +there was no fixture, stage, fan or 24 V supply. Observed: + +- Boot: clean, with the PSRAM memory test passing, reaching `ready`. The only + warning is the profile's designed `FAN-CHARACTERIZATION EXPERIMENT PROFILE` + banner, reporting the line released. +- Identity: `/api/v1/device` reports experiment profile + `fan-characterization`, the fixture pins and sink level above, `tach_ppr` + `null`, and heater and fan-control capabilities `false`. `FAN_PWM_HOLD` is + listed as available. +- GPIO6 on the DMM: 0 V at idle, after each run, and after a reset; 0 V + throughout a 0% `FAN_PWM_HOLD` (`static_release`); 3.26 V throughout a 100% + hold (`static_sink`). Readings were untimed, and the ~1 s boot window before + the firmware drives the pin was not resolved. +- Runs: each hold at 0% or 100% (25 kHz setting) passed, with `phase_start`, + `phase_end` and `run_complete` in order and `ppr`/`rpm` `null`. The status + LED was off during each run and green again after it. +- Floating GPIO7 counted between 0 and about 971,000 edges per run. This is + pickup on an unconnected input, not tach evidence. + +Still not validated: station mode (no credentials were present), host-driven +reset over the `USB` connector, the USB-UART bridge part (only its USB IDs +were seen), the LED part and supply and the state of its `RGB` pad, baseline +workload execution, the PWM waveform between 0% and 100% (LEDC frequency, duty +and transitions, pending a logic analyzer), the shutdown-release handler, the +GPIO6/7 fixture wiring and any external stage, tach capture of a real signal, +PPR, fan characterization, and electrical characterization. \ No newline at end of file diff --git a/docs/TARGET_TINYS3D.md b/docs/TARGET_TINYS3D.md index a2445c9..7310bd6 100644 --- a/docs/TARGET_TINYS3D.md +++ b/docs/TARGET_TINYS3D.md @@ -57,11 +57,39 @@ obtained a DHCP lease on the first attempt. The pin level before the firmware drove it was not measured, so why the 100 kΩ pull-down did not hold it low is unconfirmed. +## Status RGB LED + +From the TinyS3[D] schematic, Rev D-P1 (2025-06-04, `TinyS3D_P1.kicad_sch` in +Unexpected Maker's `esp32s3` repository, `series_d/schematics/`): + +- LED: WS2812B (LED2, LED1010 package) +- Data: GPIO18 (`CONFIG_DB_STATUS_RGB_GPIO=18`) +- Power: the LED's VDD is GPIO17 itself, with no switch in between + (`CONFIG_DB_STATUS_RGB_POWER_GPIO=17`). GPIO17 high powers it + (`CONFIG_DB_STATUS_RGB_POWER_ACTIVE_LEVEL=1`), matching Unexpected Maker's + TinyS3[D] helper, whose `set_pixel_power(True)` turns the pixel on. + +GPIO38 remains the separate RF-switch resource above. + +DragonBench owns this LED as its status light, so experiment fixture wiring +may not use its pins: the fan-characterization build fails if the gate or +tach is set to one. The firmware drives it through the RMT peripheral (not +LEDC or PCNT), one frame per change, with no task or timer. It is off from +boot until the `ready` event, then solid dim green (green channel 16 of 255) +while the device is ready and idle. Starting a run turns it off before the +run's `phase_start` event, and it stays off until that run has ended. It +turns green again only after a run passes; after a failed or aborted run, or +a passed `CONTROLLED_REBOOT`, it stays off until a later run passes. + +Here off also means unpowered: after a dark frame, GPIO17 is driven low, so +the LED draws nothing during a run. GPIO17 stays low from boot until the +first green, and no data is clocked into the LED while it is unpowered. + ## Validation status Flash, automatic boot from SPI flash, the ESP-IDF PSRAM memory test (8 MB added to the heap), the `ready` event, the direct access point, station association and DHCP on a home network, station credentials saved through `/api/v1/network/sta` persisting in NVS across reflashes, and mDNS on both -interfaces have been validated. The `/setup` page, workload execution, and -electrical characterization have not. +interfaces have been validated. The `/setup` page, workload execution, the +status RGB LED, and electrical characterization have not. diff --git a/firmware/common/CMakeLists.txt b/firmware/common/CMakeLists.txt index 4c23f90..e5ba77e 100644 --- a/firmware/common/CMakeLists.txt +++ b/firmware/common/CMakeLists.txt @@ -1 +1 @@ -idf_component_register(SRCS "db_run.c" "db_network.c" INCLUDE_DIRS "include") +idf_component_register(SRCS "db_run.c" "db_network.c" "db_fan.c" INCLUDE_DIRS "include") diff --git a/firmware/common/db_fan.c b/firmware/common/db_fan.c new file mode 100644 index 0000000..1ebae2f --- /dev/null +++ b/firmware/common/db_fan.c @@ -0,0 +1,229 @@ +#include "db_fan.h" + +#include +#include +#include +#include + +bool db_fan_pwm_hz_valid(uint32_t pwm_hz) { + return pwm_hz >= DB_FAN_PWM_HZ_MIN && pwm_hz <= DB_FAN_PWM_HZ_MAX; +} + +bool db_fan_sink_duty_valid(uint16_t sink_duty_tenths_pct) { + return sink_duty_tenths_pct <= DB_FAN_SINK_DUTY_TENTHS_MAX; +} + +bool db_fan_sink_duty_from_pct(double sink_duty_pct, uint16_t *tenths_out) { + // Written so NaN fails both comparisons. + if (!tenths_out || !(sink_duty_pct >= 0.0 && sink_duty_pct <= 100.0)) return false; + const double scaled = sink_duty_pct * 10.0; + const long tenths = (long)(scaled + 0.5); + const double residue = scaled - (double)tenths; + if (residue > 1e-6 || residue < -1e-6) return false; + *tenths_out = (uint16_t)tenths; + return true; +} + +const char *db_fan_stimulus_mode_name(db_fan_stimulus_mode_t mode) { + switch (mode) { + case DB_FAN_STIMULUS_STATIC_RELEASE: return "static_release"; + case DB_FAN_STIMULUS_PWM: return "pwm"; + case DB_FAN_STIMULUS_STATIC_SINK: return "static_sink"; + default: return "unknown"; + } +} + +bool db_fan_stimulus_plan(uint32_t pwm_hz, uint16_t sink_duty_tenths_pct, db_fan_stimulus_t *stimulus) { + if (!stimulus || !db_fan_pwm_hz_valid(pwm_hz) || !db_fan_sink_duty_valid(sink_duty_tenths_pct)) return false; + memset(stimulus, 0, sizeof(*stimulus)); + if (sink_duty_tenths_pct == 0) { + stimulus->mode = DB_FAN_STIMULUS_STATIC_RELEASE; + return true; + } + if (sink_duty_tenths_pct == DB_FAN_SINK_DUTY_TENTHS_MAX) { + stimulus->mode = DB_FAN_STIMULUS_STATIC_SINK; + stimulus->applied_milli_pct = 100000U; + return true; + } + uint32_t bits = DB_FAN_MAX_RESOLUTION_BITS; + while (bits > 0 && (uint64_t)pwm_hz << bits > DB_FAN_LEDC_SOURCE_HZ) --bits; + if (bits < DB_FAN_MIN_RESOLUTION_BITS) return false; + const uint64_t divider_x256 = ((uint64_t)DB_FAN_LEDC_SOURCE_HZ * 256U) / ((uint64_t)pwm_hz << bits); + if (divider_x256 < 256U || divider_x256 > DB_FAN_LEDC_DIVIDER_MAX_X256) return false; + const uint64_t full_scale = 1ULL << bits; + const uint64_t counts = + (sink_duty_tenths_pct * full_scale + DB_FAN_SINK_DUTY_TENTHS_MAX / 2U) / DB_FAN_SINK_DUTY_TENTHS_MAX; + // With >= 10 bits, 0.1 % and 99.9 % round to 1 and 2^bits-1; never a static endpoint. + if (counts == 0 || counts >= full_scale) return false; + stimulus->mode = DB_FAN_STIMULUS_PWM; + stimulus->resolution_bits = bits; + stimulus->duty_counts = (uint32_t)counts; + stimulus->applied_milli_pct = (uint32_t)((counts * 100000ULL + full_scale / 2U) / full_scale); + return true; +} + +bool db_fan_edge_hz_milli(int64_t edge_count, int64_t window_us, uint64_t *hz_milli) { + if (!hz_milli || window_us <= 0 || edge_count < 0 || edge_count > INT64_MAX / 1000000000LL) return false; + *hz_milli = (uint64_t)((edge_count * 1000000000LL + window_us / 2) / window_us); + return true; +} + +bool db_fan_rpm_milli(int64_t edge_count, int64_t window_us, uint32_t ppr, uint64_t *rpm_milli) { + if (!rpm_milli || ppr == 0 || ppr > DB_FAN_TACH_PPR_MAX || window_us <= 0 || edge_count < 0 || + edge_count > INT64_MAX / 60000000000LL || window_us > INT64_MAX / (int64_t)ppr) + return false; + const int64_t denominator = window_us * (int64_t)ppr; + *rpm_milli = (uint64_t)((edge_count * 60000000000LL + denominator / 2) / denominator); + return true; +} + +int db_fan_release_level(const db_fan_fixture_t *fixture) { return fixture->gate_sink_level ? 0 : 1; } + +bool db_fan_release(const db_fan_fixture_t *fixture) { + return fixture && fixture->ops && fixture->ops->drive_static && + fixture->ops->drive_static(fixture->ops->ctx, db_fan_release_level(fixture)); +} + +static bool actuate(const db_fan_fixture_t *fixture, uint32_t pwm_hz, const db_fan_stimulus_t *stimulus) { + const db_fan_ops_t *ops = fixture->ops; + switch (stimulus->mode) { + case DB_FAN_STIMULUS_STATIC_RELEASE: + return true; // the line is already released and verified + case DB_FAN_STIMULUS_STATIC_SINK: + return ops->drive_static(ops->ctx, fixture->gate_sink_level); + case DB_FAN_STIMULUS_PWM: + return ops->start_pwm(ops->ctx, pwm_hz, stimulus->resolution_bits, stimulus->duty_counts, + fixture->gate_sink_level == 0); + default: + return false; + } +} + +db_fan_hold_outcome_t db_fan_hold(const db_fan_fixture_t *fixture, uint32_t pwm_hz, uint16_t sink_duty_tenths_pct, + uint32_t duration_ms, db_fan_hold_result_t *result) { + if (!result) return DB_FAN_HOLD_FAIL; + memset(result, 0, sizeof(*result)); + if (!fixture || !fixture->ops || (fixture->gate_sink_level != 0 && fixture->gate_sink_level != 1)) { + result->error = "no_fixture"; + return DB_FAN_HOLD_FAIL; + } + const db_fan_ops_t *ops = fixture->ops; + db_fan_hold_outcome_t outcome = DB_FAN_HOLD_PASS; + if (duration_ms == 0 || !db_fan_stimulus_plan(pwm_hz, sink_duty_tenths_pct, &result->stimulus)) { + result->error = "invalid_parameters"; + outcome = DB_FAN_HOLD_FAIL; + goto release; + } + // Start from a known released line even if a previous exit path misbehaved. + if (!db_fan_release(fixture)) { + result->error = "pre_release_failed"; + outcome = DB_FAN_HOLD_FAIL; + goto release; + } + // Tach edges are the evidence this workload exists to collect: refuse + // before any actuation if capture is unavailable. + if (!ops->tach_ready(ops->ctx)) { + result->error = "tach_unavailable"; + outcome = DB_FAN_HOLD_FAIL; + goto release; + } + result->actuated = result->stimulus.mode != DB_FAN_STIMULUS_STATIC_RELEASE; + if (!actuate(fixture, pwm_hz, &result->stimulus)) { + result->error = "actuation_failed"; + outcome = DB_FAN_HOLD_FAIL; + goto release; + } + // Clear immediately before the window opens so the count and window align. + if (!ops->tach_clear(ops->ctx)) { + result->error = "tach_clear_failed"; + outcome = DB_FAN_HOLD_FAIL; + goto release; + } + result->window_start_us = ops->now_us(ops->ctx); + const int64_t deadline_us = result->window_start_us + (int64_t)duration_ms * 1000; + for (;;) { + const int64_t now_us = ops->now_us(ops->ctx); + if (now_us >= deadline_us) break; + if (ops->should_abort(ops->ctx)) { + outcome = DB_FAN_HOLD_ABORTED; + break; + } + const int64_t remaining_ms = (deadline_us - now_us + 999) / 1000; + ops->sleep_ms(ops->ctx, remaining_ms < DB_FAN_ABORT_POLL_MS ? (uint32_t)remaining_ms : DB_FAN_ABORT_POLL_MS); + } + if (ops->tach_read(ops->ctx, &result->edge_count)) { + result->tach_valid = true; + } else { + result->edge_count = 0; + result->error = "tach_read_failed"; + outcome = DB_FAN_HOLD_FAIL; + } + result->window_end_us = ops->now_us(ops->ctx); + +release: + result->released = db_fan_release(fixture); + result->released_us = ops->now_us ? ops->now_us(ops->ctx) : 0; + if (!result->released) { + if (!result->error) result->error = "release_failed"; + outcome = DB_FAN_HOLD_FAIL; + } + return outcome; +} + +__attribute__((format(printf, 4, 5))) +static bool append(char *out, size_t out_len, size_t *used, const char *format, ...) { + va_list args; + va_start(args, format); + const int written = vsnprintf(out + *used, out_len - *used, format, args); + va_end(args); + if (written < 0 || (size_t)written >= out_len - *used) return false; + *used += (size_t)written; + return true; +} + +bool db_fan_format_parameters(char *out, size_t out_len, uint32_t pwm_hz, uint16_t sink_duty_tenths_pct, + uint32_t duration_ms, const db_fan_stimulus_t *stimulus) { + if (!out || out_len == 0) return false; + size_t used = 0; + bool ok = append(out, out_len, &used, + "{\"pwm_hz\":%" PRIu32 ",\"sink_duty_pct\":%u.%u,\"duration_ms\":%" PRIu32, pwm_hz, + sink_duty_tenths_pct / 10U, sink_duty_tenths_pct % 10U, duration_ms); + if (stimulus) { + ok = ok && append(out, out_len, &used, ",\"stimulus\":\"%s\",\"applied_sink_duty_pct\":%" PRIu32 ".%03" PRIu32, + db_fan_stimulus_mode_name(stimulus->mode), stimulus->applied_milli_pct / 1000U, + stimulus->applied_milli_pct % 1000U); + if (stimulus->mode == DB_FAN_STIMULUS_PWM) + ok = ok && append(out, out_len, &used, ",\"duty_resolution_bits\":%" PRIu32, stimulus->resolution_bits); + } + return ok && append(out, out_len, &used, "}"); +} + +bool db_fan_format_metrics(char *out, size_t out_len, const db_fan_hold_result_t *result, uint32_t ppr) { + if (!out || out_len == 0 || !result) return false; + size_t used = 0; + bool ok = append(out, out_len, &used, "{"); + if (result->tach_valid) { + const int64_t window_us = result->window_end_us - result->window_start_us; + uint64_t hz_milli = 0, rpm_milli = 0; + ok = ok && append(out, out_len, &used, "\"tach_edges\":%" PRId64 ",\"window_start_us\":%" PRId64 + ",\"window_us\":%" PRId64, result->edge_count, result->window_start_us, window_us); + if (db_fan_edge_hz_milli(result->edge_count, window_us, &hz_milli)) + ok = ok && append(out, out_len, &used, ",\"edge_hz\":%" PRIu64 ".%03" PRIu64, hz_milli / 1000U, + hz_milli % 1000U); + else + ok = ok && append(out, out_len, &used, ",\"edge_hz\":null"); + // Unknown PPR (0) stays an explicit null and RPM is never derived. + if (ppr != 0 && db_fan_rpm_milli(result->edge_count, window_us, ppr, &rpm_milli)) + ok = ok && append(out, out_len, &used, ",\"ppr\":%" PRIu32 ",\"rpm\":%" PRIu64 ".%03" PRIu64, ppr, + rpm_milli / 1000U, rpm_milli % 1000U); + else + ok = ok && append(out, out_len, &used, ",\"ppr\":%s,\"rpm\":null", ppr ? "\"invalid\"" : "null"); + ok = ok && append(out, out_len, &used, ","); + } + if (result->released) + ok = ok && append(out, out_len, &used, "\"released_us\":%" PRId64, result->released_us); + else + ok = ok && append(out, out_len, &used, "\"released\":false"); + if (result->error) ok = ok && append(out, out_len, &used, ",\"fixture_error\":\"%s\"", result->error); + return ok && append(out, out_len, &used, "}"); +} diff --git a/firmware/common/db_run.c b/firmware/common/db_run.c index 919db5e..f6f7f07 100644 --- a/firmware/common/db_run.c +++ b/firmware/common/db_run.c @@ -1,5 +1,7 @@ #include "db_run.h" +#include "db_fan.h" + #include #include #include @@ -7,7 +9,7 @@ static const char *const names[DB_WORKLOAD_COUNT] = { "BOOT", "IDLE", "WIFI_ASSOCIATED_IDLE", "NET_TX", "NET_RX", "NET_BIDIRECTIONAL", "CPU_STRESS", "FLASH_WRITE", "NVS_WRITE", - "OTA_PARTITION_WRITE", "CONTROLLED_REBOOT" + "OTA_PARTITION_WRITE", "CONTROLLED_REBOOT", "FAN_PWM_HOLD" }; const char *db_workload_name(db_workload_t workload) { @@ -26,10 +28,15 @@ bool db_workload_parse(const char *name, db_workload_t *out) { } bool db_workload_supported(db_workload_t workload) { + if (workload == DB_FAN_PWM_HOLD) return DB_EXPERIMENT_FAN_CHARACTERIZATION; return workload >= DB_BOOT && workload < DB_WORKLOAD_COUNT; } bool db_request_validate(const db_run_request_t *request, char *error, size_t error_len) { + if (request && request->workload == DB_FAN_PWM_HOLD && !db_workload_supported(DB_FAN_PWM_HOLD)) { + snprintf(error, error_len, "FAN_PWM_HOLD requires the fan-characterization experiment profile"); + return false; + } if (!request || !db_workload_supported(request->workload)) { snprintf(error, error_len, "unsupported workload"); return false; @@ -38,6 +45,19 @@ bool db_request_validate(const db_run_request_t *request, char *error, size_t er snprintf(error, error_len, "duration_ms must be 1..3600000"); return false; } + const bool fan = request->workload == DB_FAN_PWM_HOLD; + if (!fan && (request->pwm_hz_set || request->sink_duty_set)) { + snprintf(error, error_len, "pwm_hz and sink_duty_pct apply only to FAN_PWM_HOLD"); + return false; + } + if (fan && (!request->pwm_hz_set || !db_fan_pwm_hz_valid(request->pwm_hz))) { + snprintf(error, error_len, "pwm_hz must be %u..%u", DB_FAN_PWM_HZ_MIN, DB_FAN_PWM_HZ_MAX); + return false; + } + if (fan && (!request->sink_duty_set || !db_fan_sink_duty_valid(request->sink_duty_tenths_pct))) { + snprintf(error, error_len, "sink_duty_pct must be 0..100 in 0.1 steps"); + return false; + } const bool network = request->workload == DB_NET_TX || request->workload == DB_NET_RX || request->workload == DB_NET_BIDIRECTIONAL; if (network && (request->host[0] == '\0' || request->port == 0)) { @@ -76,3 +96,10 @@ void db_run_finish(db_run_t *run, const char *result, uint64_t now_ms) { run->ended_ms = now_ms; snprintf(run->result, sizeof(run->result), "%s", result ? result : "fail"); } + +bool db_status_ready(bool device_ready, const db_run_t *run) { + if (!device_ready || !run) return false; + if (run->state == DB_RUN_IDLE) return true; + return run->state == DB_RUN_COMPLETE && strcmp(run->result, "pass") == 0 && + run->request.workload != DB_CONTROLLED_REBOOT; +} diff --git a/firmware/common/include/db_experiment.h b/firmware/common/include/db_experiment.h new file mode 100644 index 0000000..5d1d0c9 --- /dev/null +++ b/firmware/common/include/db_experiment.h @@ -0,0 +1,26 @@ +#pragma once + +// A DragonBench image is one board profile (CONFIG_DB_TARGET_NAME) plus one +// experiment profile, chosen by the mutually exclusive Kconfig choice +// DB_EXPERIMENT_PROFILE. Code that belongs to an experiment is compiled only +// under its DB_EXPERIMENT_* macro; baseline carries no fixture I/O at all. +// +// Adding a profile: add a choice entry in Kconfig.projbuild, a branch below, +// an sdkconfig.defaults. overlay, and a CI build. See +// docs/EXPERIMENT_PROFILES.md. + +#if defined(ESP_PLATFORM) +#include "sdkconfig.h" +#endif + +#if defined(CONFIG_DB_EXPERIMENT_FAN_CHARACTERIZATION) && CONFIG_DB_EXPERIMENT_FAN_CHARACTERIZATION +#define DB_EXPERIMENT_FAN_CHARACTERIZATION 1 +#define DB_EXPERIMENT_PROFILE_NAME "fan-characterization" +#else +#define DB_EXPERIMENT_FAN_CHARACTERIZATION 0 +#define DB_EXPERIMENT_PROFILE_NAME "baseline" +#endif + +#if defined(ESP_PLATFORM) && DB_EXPERIMENT_FAN_CHARACTERIZATION == 0 && !defined(CONFIG_DB_EXPERIMENT_BASELINE) +#error "Unknown DB_EXPERIMENT_PROFILE selection: add it to db_experiment.h" +#endif diff --git a/firmware/common/include/db_fan.h b/firmware/common/include/db_fan.h new file mode 100644 index 0000000..be7aebe --- /dev/null +++ b/firmware/common/include/db_fan.h @@ -0,0 +1,132 @@ +#pragma once + +// Bench-only fan-characterization fixture: a PWM stimulus through an external +// open-drain transistor stage and tach edge capture. This is characterization +// equipment, not product fan control: no closed loop, no thresholds, no fan +// safety policy. Tach readings never feed back into the stimulus. +// +// Everything here is platform-neutral so the stimulus lifecycle and the tach +// arithmetic are testable on a host. GPIO and peripheral access lives only in +// firmware/targets/esp32s3/main/fan_characterization.c, behind db_fan_ops_t, +// and is compiled only into the fan-characterization experiment profile. + +#include +#include +#include + +#include "db_experiment.h" + +// Fixture generator limits, not fan specifications. The upper bound keeps at +// least DB_FAN_MIN_RESOLUTION_BITS of duty resolution (0.1 % steps) from the +// 80 MHz LEDC source; the lower bound stays inside the LEDC divider range at +// the maximum 14-bit resolution. +#define DB_FAN_PWM_HZ_MIN 10U +#define DB_FAN_PWM_HZ_MAX 50000U +#define DB_FAN_SINK_DUTY_TENTHS_MAX 1000U +#define DB_FAN_LEDC_SOURCE_HZ 80000000U +#define DB_FAN_MIN_RESOLUTION_BITS 10U +#define DB_FAN_MAX_RESOLUTION_BITS 14U +#define DB_FAN_LEDC_DIVIDER_MAX_X256 ((1024U * 256U) - 1U) +#define DB_FAN_TACH_PPR_MAX 16U +#define DB_FAN_ABORT_POLL_MS 20U + +// ESP32-S3 pins a fixture signal must never use: strapping pins (0, 3, 45, 46), +// USB D-/D+ (19, 20), pins that do not exist (22-25), in-package/SPI flash and +// PSRAM (26-32), the Octal PSRAM data pins (33-37) on octal profiles, and the +// UART0 console (43, 44). This only excludes pins known to be taken; it does +// not choose one. Usable from #if so a bad assignment fails the build. +#define DB_FAN_PIN_RESERVED(gpio, octal_psram) \ + ((gpio) < 0 || (gpio) > 48 || (gpio) == 0 || (gpio) == 3 || (gpio) == 45 || \ + (gpio) == 46 || (gpio) == 19 || (gpio) == 20 || ((gpio) >= 22 && (gpio) <= 32) || \ + ((octal_psram) && (gpio) >= 33 && (gpio) <= 37) || (gpio) == 43 || (gpio) == 44) + +// Parameters and metrics must fit one DB_EVENT_JSON_LEN event envelope. +#define DB_FAN_PARAMETERS_JSON_LEN 160 +#define DB_FAN_METRICS_JSON_LEN 256 + +// sink_duty_pct is the fraction of time the external stage sinks the fan PWM +// line. The endpoints are static gate levels, not PWM: 0 % holds the stage off +// (released) and 100 % holds it on. LEDC is used only strictly between them, +// which also keeps the compare value inside 1..2^bits-1 (ESP32-S3 LEDC must not +// be given 2^duty_resolution). +typedef enum { + DB_FAN_STIMULUS_STATIC_RELEASE, + DB_FAN_STIMULUS_PWM, + DB_FAN_STIMULUS_STATIC_SINK, +} db_fan_stimulus_mode_t; + +typedef struct { + db_fan_stimulus_mode_t mode; + uint32_t resolution_bits; // PWM only, else 0 + uint32_t duty_counts; // PWM only: LEDC high-time counts, 1..2^bits-1 + uint32_t applied_milli_pct; // sink duty actually produced, 0..100000 +} db_fan_stimulus_t; + +const char *db_fan_stimulus_mode_name(db_fan_stimulus_mode_t mode); +bool db_fan_pwm_hz_valid(uint32_t pwm_hz); +bool db_fan_sink_duty_valid(uint16_t sink_duty_tenths_pct); +// Converts a JSON number to tenths of a percent; false unless it is 0..100 in +// 0.1 steps. +bool db_fan_sink_duty_from_pct(double sink_duty_pct, uint16_t *tenths_out); +bool db_fan_stimulus_plan(uint32_t pwm_hz, uint16_t sink_duty_tenths_pct, db_fan_stimulus_t *stimulus); + +// Tach arithmetic. Edge frequency is edges per second over the capture window; +// false when the window is empty. RPM needs an explicitly configured PPR: +// db_fan_rpm_milli returns false for ppr == 0 (unknown). +bool db_fan_edge_hz_milli(int64_t edge_count, int64_t window_us, uint64_t *hz_milli); +bool db_fan_rpm_milli(int64_t edge_count, int64_t window_us, uint32_t ppr, uint64_t *rpm_milli); + +// Hardware seam, in pad terms only. Which gate level sinks is decided here in +// the common layer from gate_sink_level, so both stage polarities are testable. +// drive_static: route the pad to a plain GPIO output at gate_level and verify +// it by readback; detaches any PWM. Safe to call at any time. +// start_pwm: LEDC at pwm_hz with duty_counts high-time out of 2^bits; +// invert_output makes the high-time drive the pad low. +// tach_ready: capture path configured and usable; no side effects. +typedef struct { + bool (*drive_static)(void *ctx, int gate_level); + bool (*start_pwm)(void *ctx, uint32_t pwm_hz, uint32_t resolution_bits, uint32_t duty_counts, + bool invert_output); + bool (*tach_ready)(void *ctx); + bool (*tach_clear)(void *ctx); + bool (*tach_read)(void *ctx, int64_t *edge_count); + int64_t (*now_us)(void *ctx); + bool (*should_abort)(void *ctx); + void (*sleep_ms)(void *ctx, uint32_t ms); + void *ctx; +} db_fan_ops_t; + +typedef struct { + const db_fan_ops_t *ops; + int gate_sink_level; // 0 or 1: gate level at which the external stage sinks +} db_fan_fixture_t; + +int db_fan_release_level(const db_fan_fixture_t *fixture); + +typedef enum { DB_FAN_HOLD_PASS, DB_FAN_HOLD_ABORTED, DB_FAN_HOLD_FAIL } db_fan_hold_outcome_t; + +typedef struct { + db_fan_stimulus_t stimulus; + int64_t window_start_us; + int64_t window_end_us; + int64_t released_us; + int64_t edge_count; + bool tach_valid; + bool actuated; // a stimulus other than release was driven + bool released; + const char *error; // failing stage, or NULL +} db_fan_hold_result_t; + +// Drives the release level and verifies it. Used at boot and on every exit. +bool db_fan_release(const db_fan_fixture_t *fixture); + +// One FAN_PWM_HOLD: plan, release, confirm tach readiness, then actuate the +// planned stimulus, clear the counter and open the window, hold, read, and +// release on every exit path (end, abort, and each failure). Nothing is +// actuated unless the line was released and tach capture is ready. +db_fan_hold_outcome_t db_fan_hold(const db_fan_fixture_t *fixture, uint32_t pwm_hz, uint16_t sink_duty_tenths_pct, + uint32_t duration_ms, db_fan_hold_result_t *result); + +bool db_fan_format_parameters(char *out, size_t out_len, uint32_t pwm_hz, uint16_t sink_duty_tenths_pct, + uint32_t duration_ms, const db_fan_stimulus_t *stimulus); +bool db_fan_format_metrics(char *out, size_t out_len, const db_fan_hold_result_t *result, uint32_t ppr); diff --git a/firmware/common/include/db_run.h b/firmware/common/include/db_run.h index d8f6589..f2fd930 100644 --- a/firmware/common/include/db_run.h +++ b/firmware/common/include/db_run.h @@ -7,7 +7,7 @@ #define DB_RUN_ID_LEN 33 #define DB_HOST_LEN 254 #define DB_EVENT_CAPACITY 64 -#define DB_EVENT_JSON_LEN 512 +#define DB_EVENT_JSON_LEN 640 typedef enum { DB_BOOT, @@ -21,6 +21,7 @@ typedef enum { DB_NVS_WRITE, DB_OTA_PARTITION_WRITE, DB_CONTROLLED_REBOOT, + DB_FAN_PWM_HOLD, // bench stimulus; supported only in the fan-characterization profile DB_WORKLOAD_COUNT } db_workload_t; @@ -32,6 +33,11 @@ typedef struct { uint32_t rate_bps; uint16_t port; char host[DB_HOST_LEN]; + // FAN_PWM_HOLD only. Zero-initialized requests carry no fan parameters. + bool pwm_hz_set; + bool sink_duty_set; + uint32_t pwm_hz; + uint16_t sink_duty_tenths_pct; } db_run_request_t; typedef struct { @@ -56,3 +62,7 @@ bool db_request_validate(const db_run_request_t *request, char *error, size_t er void db_run_begin(db_run_t *run, const db_run_request_t *request, const char *run_id, uint64_t now_ms); bool db_run_abort(db_run_t *run, const char *run_id); void db_run_finish(db_run_t *run, const char *result, uint64_t now_ms); +// Whether the status light may show ready (green): the device has reached its +// ready state, no run is in progress, and the last run, if any, passed and is +// not a CONTROLLED_REBOOT about to restart. Anything else keeps it off. +bool db_status_ready(bool device_ready, const db_run_t *run); diff --git a/firmware/targets/esp32s3/main/CMakeLists.txt b/firmware/targets/esp32s3/main/CMakeLists.txt index f669924..556e1d3 100644 --- a/firmware/targets/esp32s3/main/CMakeLists.txt +++ b/firmware/targets/esp32s3/main/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( - SRCS "main.c" + SRCS "main.c" "fan_characterization.c" "status_rgb.c" INCLUDE_DIRS "." REQUIRES common @@ -13,6 +13,21 @@ idf_component_register( esp_partition esp_driver_tsens esp_driver_gpio + esp_driver_ledc + esp_driver_pcnt + esp_driver_rmt esp_timer json ) + +# Source revision, regenerated on every build (see build_provenance.cmake). +idf_build_get_property(project_dir PROJECT_DIR) +set(provenance_header "${CMAKE_CURRENT_BINARY_DIR}/provenance/db_build_provenance.h") +add_custom_target(db_build_provenance + COMMAND ${CMAKE_COMMAND} -DSOURCE_DIR=${project_dir} -DOUTPUT=${provenance_header} + -P ${CMAKE_CURRENT_LIST_DIR}/build_provenance.cmake + BYPRODUCTS ${provenance_header} + COMMENT "Recording DragonBench source revision" + VERBATIM) +add_dependencies(${COMPONENT_LIB} db_build_provenance) +target_include_directories(${COMPONENT_LIB} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/provenance") diff --git a/firmware/targets/esp32s3/main/Kconfig.projbuild b/firmware/targets/esp32s3/main/Kconfig.projbuild index 050828a..d734f12 100644 --- a/firmware/targets/esp32s3/main/Kconfig.projbuild +++ b/firmware/targets/esp32s3/main/Kconfig.projbuild @@ -30,4 +30,106 @@ config DB_RF_SWITCH_GPIO help Driven low at boot to select the onboard antenna. TinyS3[D]: GPIO38 (high selects the external U.FL connector). +config DB_STATUS_RGB_GPIO + int "Onboard WS2812 status RGB LED data GPIO (-1 if the board has none)" + range -1 48 + default -1 + help + Board-owned: DragonBench drives this LED as its status light (solid + green when ready and idle, off at boot and throughout every run), + and experiment fixture wiring may not claim the pin. + Set per board by sdkconfig.defaults[.]. + N8R8 and N16R8: GPIO48. TinyS3[D]: GPIO18. +config DB_STATUS_RGB_POWER_GPIO + int "GPIO that powers the status RGB LED (-1 if it is always powered)" + range -1 48 + default -1 + help + Board-owned like DB_STATUS_RGB_GPIO. The LED is unpowered whenever + it is off. TinyS3[D]: GPIO17 supplies the LED's VDD directly. +config DB_STATUS_RGB_POWER_ACTIVE_LEVEL + int "DB_STATUS_RGB_POWER_GPIO level that powers the LED" + range 0 1 + default 1 + +choice DB_EXPERIMENT_PROFILE + prompt "Experiment profile" + default DB_EXPERIMENT_BASELINE + help + A DragonBench image is exactly one board profile plus exactly one + experiment profile. An experiment profile is a narrow, reviewed + characterization setup, not a bundle of feature flags; each one + brings only the stimulus/acquisition code and fixture settings it + needs. Select one with its sdkconfig.defaults. overlay. + See docs/EXPERIMENT_PROFILES.md. + +config DB_EXPERIMENT_BASELINE + bool "baseline: module workloads only, no fixture I/O" + help + The ESP32-S3 module characterization workloads. No stimulus or + acquisition hardware beyond the module itself. + +config DB_EXPERIMENT_FAN_CHARACTERIZATION + bool "fan-characterization: bench PWM stimulus + tach (NOT product fan control)" + help + Bench-only stimulus/capture fixture for characterizing a fan + specimen: the FAN_PWM_HOLD workload drives the gate of an EXTERNAL + open-drain transistor stage that sinks the fan PWM line, and PCNT + counts tach edges. No closed loop, no thresholds, no heater, and no + product fan-control or safety policy. Select it with + sdkconfig.defaults.fan-characterization plus a local overlay that + states the wired GPIOs. See docs/FAN_CHARACTERIZATION.md. + +endchoice + +if DB_EXPERIMENT_FAN_CHARACTERIZATION +menu "Fan-characterization fixture wiring" +config DB_FAN_PWM_GATE_GPIO + int "Stimulus gate GPIO (-1: unset, build fails)" + range -1 48 + default -1 + help + GPIO wired to the gate/base of the external open-drain stage. It + never connects to the fan PWM lead directly. No default: the fixture + wiring decides it. +config DB_FAN_GATE_SINK_LEVEL + int "Gate level at which the stage sinks the fan PWM line (-1: unset)" + range -1 1 + default -1 + help + 1 for a non-inverting stage (for example an N-channel MOSFET gate or + NPN base: high sinks), 0 for an inverting one. The opposite level is + the release level used at boot, run end, abort, and every error. +config DB_FAN_TACH_GPIO + int "Conditioned tach input GPIO (-1: unset, build fails)" + range -1 48 + default -1 + help + GPIO receiving the tach signal after the fixture's pull-up/level + conditioning. It must never see more than 3.3 V. +config DB_FAN_TACH_INTERNAL_PULLUP + bool "Enable the ESP32-S3 internal pull-up on the tach input" + default n + help + Off by default: the fixture is expected to provide an external + pull-up to 3.3 V sized for the tach output. +config DB_FAN_TACH_GLITCH_FILTER_NS + int "PCNT glitch filter on the tach input, ns (0: off)" + range 0 12700 + default 0 +config DB_FAN_TACH_PPR + int "Tach pulses per revolution (0: unknown, RPM not reported)" + range 0 16 + default 0 + help + Leave 0 until PPR is established for the specimen by independent + evidence. With 0, events report edge frequency and "ppr": null only. +config DB_FAN_TACH_PPR_EVIDENCE + string "Evidence reference establishing the configured PPR" + default "" + help + Required when DB_FAN_TACH_PPR is non-zero, for example the bench + record that compared tach edges with an optical tachometer. +endmenu +endif endmenu diff --git a/firmware/targets/esp32s3/main/build_provenance.cmake b/firmware/targets/esp32s3/main/build_provenance.cmake new file mode 100644 index 0000000..c0a4b1a --- /dev/null +++ b/firmware/targets/esp32s3/main/build_provenance.cmake @@ -0,0 +1,43 @@ +# Writes db_build_provenance.h with the source revision this image was built +# from. Run on every build (cmake -P) and rewrites the header only when its +# content changes, so an incremental build never reports a stale revision and +# an unchanged revision does not force a recompile. +# +# Reports only what git can establish: without git, outside a work tree, or if +# a query fails, the SHA is empty and the tree state is "unknown". +# +# Inputs: -DSOURCE_DIR= -DOUTPUT=
+ +set(git_sha "") +set(source_tree "unknown") +find_program(GIT_EXECUTABLE git) +if(GIT_EXECUTABLE) + # Containerized CI checkouts are often owned by another user. + set(git ${GIT_EXECUTABLE} -c safe.directory=${SOURCE_DIR} -C ${SOURCE_DIR}) + execute_process(COMMAND ${git} rev-parse --verify HEAD + OUTPUT_VARIABLE head RESULT_VARIABLE head_result + OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) + if(head_result EQUAL 0 AND head MATCHES "^[0-9a-f]+$") + set(git_sha "${head}") + # Tracked changes and untracked, non-ignored files both count as dirty. + execute_process(COMMAND ${git} status --porcelain + OUTPUT_VARIABLE status RESULT_VARIABLE status_result ERROR_QUIET) + if(status_result EQUAL 0) + if(status STREQUAL "") + set(source_tree "clean") + else() + set(source_tree "dirty") + endif() + endif() + endif() +endif() + +set(content "// Generated by build_provenance.cmake. Do not edit.\n#pragma once\n#define DB_BUILD_GIT_SHA \"${git_sha}\"\n#define DB_BUILD_SOURCE_TREE \"${source_tree}\"\n") +if(EXISTS "${OUTPUT}") + file(READ "${OUTPUT}" previous) +else() + set(previous "") +endif() +if(NOT previous STREQUAL content) + file(WRITE "${OUTPUT}" "${content}") +endif() diff --git a/firmware/targets/esp32s3/main/fan_characterization.c b/firmware/targets/esp32s3/main/fan_characterization.c new file mode 100644 index 0000000..58add52 --- /dev/null +++ b/firmware/targets/esp32s3/main/fan_characterization.c @@ -0,0 +1,273 @@ +#include "fan_characterization.h" + +#if DB_EXPERIMENT_FAN_CHARACTERIZATION + +#include "driver/gpio.h" +#include "driver/ledc.h" +#include "driver/pulse_cnt.h" +#include "esp_log.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +// The fixture wiring is bench hardware DragonBench cannot know. Nothing has a +// usable default: an unconfigured fixture build fails here instead of guessing. +#if CONFIG_DB_FAN_PWM_GATE_GPIO < 0 +#error "fan-characterization: set CONFIG_DB_FAN_PWM_GATE_GPIO to the GPIO wired to the external open-drain stage gate (docs/FAN_CHARACTERIZATION.md)" +#endif +#if CONFIG_DB_FAN_GATE_SINK_LEVEL < 0 +#error "fan-characterization: set CONFIG_DB_FAN_GATE_SINK_LEVEL to the gate level (0 or 1) at which the external stage sinks the fan PWM line (docs/FAN_CHARACTERIZATION.md)" +#endif +#if CONFIG_DB_FAN_TACH_GPIO < 0 +#error "fan-characterization: set CONFIG_DB_FAN_TACH_GPIO to the GPIO wired to the conditioned tach signal (docs/FAN_CHARACTERIZATION.md)" +#endif +#if CONFIG_DB_FAN_PWM_GATE_GPIO >= 0 && CONFIG_DB_FAN_GATE_SINK_LEVEL >= 0 && CONFIG_DB_FAN_TACH_GPIO >= 0 + +#if defined(CONFIG_SPIRAM_MODE_OCT) +#define FAN_OCTAL_PSRAM 1 +#else +#define FAN_OCTAL_PSRAM 0 +#endif +#if DB_FAN_PIN_RESERVED(CONFIG_DB_FAN_PWM_GATE_GPIO, FAN_OCTAL_PSRAM) +#error "fan-characterization: CONFIG_DB_FAN_PWM_GATE_GPIO is a strapping, USB, flash/PSRAM, console, or nonexistent pin" +#endif +#if DB_FAN_PIN_RESERVED(CONFIG_DB_FAN_TACH_GPIO, FAN_OCTAL_PSRAM) +#error "fan-characterization: CONFIG_DB_FAN_TACH_GPIO is a strapping, USB, flash/PSRAM, console, or nonexistent pin" +#endif +#if CONFIG_DB_FAN_PWM_GATE_GPIO == CONFIG_DB_FAN_TACH_GPIO +#error "fan-characterization: gate and tach must be different GPIOs" +#endif +#if CONFIG_DB_FAN_PWM_GATE_GPIO == CONFIG_DB_RF_SWITCH_GPIO || CONFIG_DB_FAN_TACH_GPIO == CONFIG_DB_RF_SWITCH_GPIO +#error "fan-characterization: GPIO collides with CONFIG_DB_RF_SWITCH_GPIO" +#endif +// The board's status RGB LED pins belong to DragonBench's status light. +#if CONFIG_DB_FAN_PWM_GATE_GPIO == CONFIG_DB_STATUS_RGB_GPIO || CONFIG_DB_FAN_TACH_GPIO == CONFIG_DB_STATUS_RGB_GPIO +#error "fan-characterization: GPIO collides with CONFIG_DB_STATUS_RGB_GPIO, the board's status RGB LED data pin" +#endif +#if CONFIG_DB_FAN_PWM_GATE_GPIO == CONFIG_DB_STATUS_RGB_POWER_GPIO || CONFIG_DB_FAN_TACH_GPIO == CONFIG_DB_STATUS_RGB_POWER_GPIO +#error "fan-characterization: GPIO collides with CONFIG_DB_STATUS_RGB_POWER_GPIO, the board's status RGB LED power pin" +#endif +// RPM is derived only from a PPR that names the evidence that established it. +_Static_assert(CONFIG_DB_FAN_TACH_PPR == 0 || sizeof(CONFIG_DB_FAN_TACH_PPR_EVIDENCE) > 1, + "fan-characterization: CONFIG_DB_FAN_TACH_PPR needs CONFIG_DB_FAN_TACH_PPR_EVIDENCE"); + +#if defined(CONFIG_DB_FAN_TACH_INTERNAL_PULLUP) +#define TACH_INTERNAL_PULLUP 1 +#else +#define TACH_INTERNAL_PULLUP 0 +#endif + +#define TAG "fan_characterization" +#define GATE_GPIO ((gpio_num_t)CONFIG_DB_FAN_PWM_GATE_GPIO) +#define TACH_GPIO ((gpio_num_t)CONFIG_DB_FAN_TACH_GPIO) +#define GATE_SINK_LEVEL CONFIG_DB_FAN_GATE_SINK_LEVEL +#define GATE_RELEASE_LEVEL (GATE_SINK_LEVEL ? 0 : 1) +#define FAN_LEDC_MODE LEDC_LOW_SPEED_MODE +#define FAN_LEDC_TIMER LEDC_TIMER_0 +#define FAN_LEDC_CHANNEL LEDC_CHANNEL_0 +#define TACH_HIGH_LIMIT 32767 + +typedef enum { + LINE_UNCONFIGURED, + LINE_RELEASED, + LINE_STATIC_SINK, + LINE_PWM, + LINE_RELEASE_FAILED, + LINE_DRIVE_FAILED, +} line_state_t; + +static volatile line_state_t line_state = LINE_UNCONFIGURED; +static bool ledc_channel_active; +static pcnt_unit_handle_t tach_unit; +static bool tach_ready; +static bool (*abort_requested)(void); + +static const char *line_state_name(line_state_t state) { + switch (state) { + case LINE_RELEASED: return "released"; + case LINE_STATIC_SINK: return "static_sink"; + case LINE_PWM: return "pwm"; + case LINE_RELEASE_FAILED: return "release_failed"; + case LINE_DRIVE_FAILED: return "drive_failed"; + default: return "unconfigured"; + } +} + +// Routes the gate pad to a plain GPIO output at gate_level, detaching LEDC +// (output register first, so the switch cannot glitch to the other level), +// then reads the pad back. No internal pulls: the external gate/base bias owns +// the level whenever the pin is not driven, including reset and boot. +static bool drive_static(void *ctx, int gate_level) { + (void)ctx; + gpio_set_level(GATE_GPIO, gate_level); + const gpio_config_t gate = { + .pin_bit_mask = 1ULL << CONFIG_DB_FAN_PWM_GATE_GPIO, + .mode = GPIO_MODE_INPUT_OUTPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + const bool ok = gpio_config(&gate) == ESP_OK && gpio_set_level(GATE_GPIO, gate_level) == ESP_OK && + gpio_get_level(GATE_GPIO) == gate_level; + if (ledc_channel_active) { + // The pad is already off LEDC; this only stops the channel. + ledc_stop(FAN_LEDC_MODE, FAN_LEDC_CHANNEL, 0); + ledc_channel_active = false; + } + const bool release = gate_level == GATE_RELEASE_LEVEL; + line_state = ok ? (release ? LINE_RELEASED : LINE_STATIC_SINK) : (release ? LINE_RELEASE_FAILED : LINE_DRIVE_FAILED); + return ok; +} + +// Used only strictly between 0 % and 100 % sink, so duty_counts is always +// 1..2^bits-1 (ESP32-S3 LEDC must not be given 2^duty_resolution). +static bool start_pwm(void *ctx, uint32_t pwm_hz, uint32_t resolution_bits, uint32_t duty_counts, bool invert_output) { + (void)ctx; + if (duty_counts == 0 || duty_counts >= (1UL << resolution_bits)) return false; + const ledc_timer_config_t timer = { + .speed_mode = FAN_LEDC_MODE, + .duty_resolution = (ledc_timer_bit_t)resolution_bits, + .timer_num = FAN_LEDC_TIMER, + .freq_hz = pwm_hz, + .clk_cfg = LEDC_USE_APB_CLK, + }; + if (ledc_timer_config(&timer) != ESP_OK) return false; + const ledc_channel_config_t channel = { + .gpio_num = CONFIG_DB_FAN_PWM_GATE_GPIO, + .speed_mode = FAN_LEDC_MODE, + .channel = FAN_LEDC_CHANNEL, + .intr_type = LEDC_INTR_DISABLE, + .timer_sel = FAN_LEDC_TIMER, + .duty = duty_counts, + .hpoint = 0, + .flags.output_invert = invert_output ? 1 : 0, + }; + ledc_channel_active = true; + if (ledc_channel_config(&channel) != ESP_OK) return false; + line_state = LINE_PWM; + return true; +} + +// Readiness without side effects: the unit exists and its count is readable. +static bool tach_is_ready(void *ctx) { + (void)ctx; + int count = 0; + return tach_ready && pcnt_unit_get_count(tach_unit, &count) == ESP_OK; +} + +static bool tach_clear(void *ctx) { + (void)ctx; + return tach_ready && pcnt_unit_clear_count(tach_unit) == ESP_OK; +} + +static bool tach_read(void *ctx, int64_t *edge_count) { + (void)ctx; + int count = 0; + if (!tach_ready || pcnt_unit_get_count(tach_unit, &count) != ESP_OK || count < 0) return false; + *edge_count = count; + return true; +} + +static int64_t now_us(void *ctx) { + (void)ctx; + return esp_timer_get_time(); +} + +static bool hold_should_abort(void *ctx) { + (void)ctx; + return abort_requested && abort_requested(); +} + +static void sleep_ms(void *ctx, uint32_t ms) { + (void)ctx; + const TickType_t ticks = pdMS_TO_TICKS(ms); + vTaskDelay(ticks ? ticks : 1); +} + +static const db_fan_ops_t ops = { + .drive_static = drive_static, + .start_pwm = start_pwm, + .tach_ready = tach_is_ready, + .tach_clear = tach_clear, + .tach_read = tach_read, + .now_us = now_us, + .should_abort = hold_should_abort, + .sleep_ms = sleep_ms, +}; + +static const db_fan_fixture_t fixture = {.ops = &ops, .gate_sink_level = GATE_SINK_LEVEL}; + +// Counts falling edges only, so edges equal tach pulses; the accumulating +// watch point at the 16-bit limit extends the hardware counter. +static bool tach_init(void) { + const pcnt_unit_config_t unit_config = { + .low_limit = -1, + .high_limit = TACH_HIGH_LIMIT, + .flags.accum_count = 1, + }; + if (pcnt_new_unit(&unit_config, &tach_unit) != ESP_OK) return false; +#if CONFIG_DB_FAN_TACH_GLITCH_FILTER_NS > 0 + const pcnt_glitch_filter_config_t filter = {.max_glitch_ns = CONFIG_DB_FAN_TACH_GLITCH_FILTER_NS}; + if (pcnt_unit_set_glitch_filter(tach_unit, &filter) != ESP_OK) return false; +#endif + const pcnt_chan_config_t channel_config = {.edge_gpio_num = CONFIG_DB_FAN_TACH_GPIO, .level_gpio_num = -1}; + pcnt_channel_handle_t channel = NULL; + if (pcnt_new_channel(tach_unit, &channel_config, &channel) != ESP_OK || + pcnt_channel_set_edge_action(channel, PCNT_CHANNEL_EDGE_ACTION_HOLD, PCNT_CHANNEL_EDGE_ACTION_INCREASE) != ESP_OK) + return false; + // pcnt_new_channel enables the internal pull-up; apply the fixture's explicit choice. + if (gpio_set_pull_mode(TACH_GPIO, TACH_INTERNAL_PULLUP ? GPIO_PULLUP_ONLY : GPIO_FLOATING) != ESP_OK) return false; + return pcnt_unit_add_watch_point(tach_unit, TACH_HIGH_LIMIT) == ESP_OK && pcnt_unit_enable(tach_unit) == ESP_OK && + pcnt_unit_clear_count(tach_unit) == ESP_OK && pcnt_unit_start(tach_unit) == ESP_OK; +} + +static void shutdown_release(void) { db_fan_release(&fixture); } + +void fan_characterization_boot(void) { + if (!db_fan_release(&fixture)) ESP_LOGE(TAG, "gate release could not be verified at boot"); + tach_ready = tach_init(); + if (!tach_ready) ESP_LOGE(TAG, "tach capture unavailable on GPIO%d", CONFIG_DB_FAN_TACH_GPIO); + if (esp_register_shutdown_handler(shutdown_release) != ESP_OK) ESP_LOGW(TAG, "no shutdown release handler"); + ESP_LOGW(TAG, "FAN-CHARACTERIZATION EXPERIMENT PROFILE (bench stimulus, not product fan control): gate GPIO%d (sink level %d), tach GPIO%d, line %s", + CONFIG_DB_FAN_PWM_GATE_GPIO, GATE_SINK_LEVEL, CONFIG_DB_FAN_TACH_GPIO, line_state_name(line_state)); +} + +db_fan_hold_outcome_t fan_characterization_hold(const db_run_request_t *request, bool (*should_abort)(void), + db_fan_hold_result_t *result) { + abort_requested = should_abort; + const db_fan_hold_outcome_t outcome = + db_fan_hold(&fixture, request->pwm_hz, request->sink_duty_tenths_pct, request->duration_ms, result); + abort_requested = NULL; + return outcome; +} + +bool fan_characterization_tach_ready(void) { return tach_is_ready(NULL); } + +uint32_t fan_characterization_ppr(void) { return CONFIG_DB_FAN_TACH_PPR; } + +void fan_characterization_describe(cJSON *parent) { + cJSON *o = cJSON_AddObjectToObject(parent, "fan_fixture"); + cJSON_AddStringToObject(o, "stimulus", "pwm_sink_via_external_open_drain_stage"); + cJSON_AddStringToObject(o, "fan_response", "uncharacterized"); + cJSON_AddStringToObject(o, "line_state", line_state_name(line_state)); + cJSON_AddNumberToObject(o, "pwm_gate_gpio", CONFIG_DB_FAN_PWM_GATE_GPIO); + cJSON_AddNumberToObject(o, "gate_sink_level", GATE_SINK_LEVEL); + cJSON_AddNumberToObject(o, "pwm_hz_min", DB_FAN_PWM_HZ_MIN); + cJSON_AddNumberToObject(o, "pwm_hz_max", DB_FAN_PWM_HZ_MAX); + cJSON_AddNumberToObject(o, "tach_gpio", CONFIG_DB_FAN_TACH_GPIO); + cJSON_AddStringToObject(o, "tach_edge", "falling"); + cJSON_AddBoolToObject(o, "tach_ready", tach_ready); + cJSON_AddBoolToObject(o, "tach_internal_pullup", TACH_INTERNAL_PULLUP); + cJSON_AddNumberToObject(o, "tach_glitch_filter_ns", CONFIG_DB_FAN_TACH_GLITCH_FILTER_NS); + if (CONFIG_DB_FAN_TACH_PPR > 0) { + cJSON_AddNumberToObject(o, "tach_ppr", CONFIG_DB_FAN_TACH_PPR); + cJSON_AddStringToObject(o, "tach_ppr_evidence", CONFIG_DB_FAN_TACH_PPR_EVIDENCE); + } else { + cJSON_AddNullToObject(o, "tach_ppr"); + } +} + +#endif // fixture configured + +#endif diff --git a/firmware/targets/esp32s3/main/fan_characterization.h b/firmware/targets/esp32s3/main/fan_characterization.h new file mode 100644 index 0000000..f37a020 --- /dev/null +++ b/firmware/targets/esp32s3/main/fan_characterization.h @@ -0,0 +1,28 @@ +#pragma once + +// ESP32-S3 backend of the fan-characterization experiment profile: bench-only +// stimulus and tach capture. Compiled only when that profile is selected; +// baseline contains none of it. + +#include "db_fan.h" +#include "db_run.h" + +#if DB_EXPERIMENT_FAN_CHARACTERIZATION +#include "cJSON.h" + +// Drives the stimulus gate to its release level, then prepares tach capture. +// Call first in app_main, before anything that could delay it. +void fan_characterization_boot(void); + +db_fan_hold_outcome_t fan_characterization_hold(const db_run_request_t *request, bool (*should_abort)(void), + db_fan_hold_result_t *result); + +// Tach capture configured and readable; required before any FAN_PWM_HOLD. +bool fan_characterization_tach_ready(void); + +// Configured pulses per revolution; 0 means unknown and RPM is never derived. +uint32_t fan_characterization_ppr(void); + +// Adds the static fixture configuration and live line state to a device object. +void fan_characterization_describe(cJSON *parent); +#endif diff --git a/firmware/targets/esp32s3/main/main.c b/firmware/targets/esp32s3/main/main.c index f869aa5..85563a1 100644 --- a/firmware/targets/esp32s3/main/main.c +++ b/firmware/targets/esp32s3/main/main.c @@ -7,8 +7,13 @@ #include #include "cJSON.h" +#include "db_build_provenance.h" +#include "db_experiment.h" +#include "db_fan.h" #include "db_network.h" #include "db_run.h" +#include "fan_characterization.h" +#include "status_rgb.h" #include "driver/gpio.h" #include "driver/temperature_sensor.h" #include "esp_attr.h" @@ -59,6 +64,9 @@ static char sta_ssid[33]; static char ap_ip[16]; static char sta_ip[16]; static bool mdns_ready; +// Guarded by state_lock, like current_run. +static bool device_ready; +static bool status_green; static char mdns_hostname[MDNS_NAME_BUF_LEN]; static EventGroupHandle_t network_events; #define AP_STARTED_BIT BIT0 @@ -123,14 +131,24 @@ static void emit_event(const char *event, const char *phase, const char *run_id, if (metrics) cJSON_AddItemToObject(root, "metrics", metrics); } char *json = cJSON_PrintUnformatted(root); + ESP_LOGI(TAG, "%s", json ? json : "{}"); + // Never store a cut-off (invalid) JSON line: an oversized event keeps its + // envelope, drops parameters/metrics, and says so. The log has it all. + char *stored = json; + if (json && strlen(json) >= DB_EVENT_JSON_LEN) { + cJSON_DeleteItemFromObject(root, "parameters"); + cJSON_DeleteItemFromObject(root, "metrics"); + cJSON_AddTrueToObject(root, "truncated"); + stored = cJSON_PrintUnformatted(root); + } xSemaphoreTake(state_lock, portMAX_DELAY); db_event_t *slot = &events[event_head]; slot->seq = event_seq; - snprintf(slot->json, sizeof(slot->json), "%s", json ? json : "{}"); + snprintf(slot->json, sizeof(slot->json), "%s", stored ? stored : "{}"); event_head = (event_head + 1U) % DB_EVENT_CAPACITY; if (event_count < DB_EVENT_CAPACITY) ++event_count; xSemaphoreGive(state_lock); - ESP_LOGI(TAG, "%s", json ? json : "{}"); + if (stored != json) cJSON_free(stored); cJSON_free(json); cJSON_Delete(root); } @@ -261,6 +279,31 @@ static bool run_network(const db_run_request_t *request, uint64_t *tx, uint64_t return ok && !should_abort(); } +static void format_parameters(const db_run_request_t *request, char *out, size_t out_len) { + if (request->workload == DB_FAN_PWM_HOLD) { + db_fan_stimulus_t stimulus; + const bool planned = db_fan_stimulus_plan(request->pwm_hz, request->sink_duty_tenths_pct, &stimulus); + db_fan_format_parameters(out, out_len, request->pwm_hz, request->sink_duty_tenths_pct, + request->duration_ms, planned ? &stimulus : NULL); + return; + } + snprintf(out, out_len, + "{\"duration_ms\":%" PRIu32 ",\"rate_bps\":%" PRIu32 + ",\"host\":\"%s\",\"port\":%u}", + request->duration_ms, request->rate_bps, request->host, request->port); +} + +// Caller holds state_lock. The status light follows device and run state +// only: green when ready and idle after a pass (db_status_ready), otherwise +// off. Called on every run state change, under the same lock, so the light is +// off before a run's phase_start and cannot turn green once another run began. +static void status_sync_locked(void) { + const bool green = db_status_ready(device_ready, ¤t_run); + if (green == status_green) return; + status_rgb_set(green); + status_green = green; +} + static void workload_task(void *unused) { (void)unused; db_run_t run; @@ -269,13 +312,12 @@ static void workload_task(void *unused) { xSemaphoreGive(state_lock); const char *phase = db_workload_name(run.request.workload); char parameters[384]; - snprintf(parameters, sizeof(parameters), - "{\"duration_ms\":%" PRIu32 ",\"rate_bps\":%" PRIu32 - ",\"host\":\"%s\",\"port\":%u}", - run.request.duration_ms, run.request.rate_bps, run.request.host, run.request.port); + format_parameters(&run.request, parameters, sizeof(parameters)); emit_event("phase_start", phase, run.run_id, NULL, parameters, NULL); bool ok = true; + bool fixture_fault = false; uint64_t a = 0, b = 0; + char metrics[DB_FAN_METRICS_JSON_LEN] = ""; switch (run.request.workload) { case DB_BOOT: case DB_IDLE: @@ -302,15 +344,28 @@ static void workload_task(void *unused) { } case DB_CONTROLLED_REBOOT: ok = wait_abortable(run.request.duration_ms); break; +#if DB_EXPERIMENT_FAN_CHARACTERIZATION + case DB_FAN_PWM_HOLD: { + db_fan_hold_result_t hold; + ok = fan_characterization_hold(&run.request, should_abort, &hold) == DB_FAN_HOLD_PASS; + // A fixture error (including a failed release) is a fault even during abort. + fixture_fault = hold.error != NULL; + if (!db_fan_format_metrics(metrics, sizeof(metrics), &hold, fan_characterization_ppr())) + snprintf(metrics, sizeof(metrics), "{\"metrics_overflow\":true,\"released\":%s}", + hold.released ? "true" : "false"); + break; + } +#endif default: ok = false; break; } const char *result = should_abort() ? "aborted" : (ok ? "pass" : "fail"); - char metrics[128]; - snprintf(metrics, sizeof(metrics), "{\"operations_or_bytes\":%" PRIu64 ",\"bytes_rx\":%" PRIu64 "}", a, b); - if (!ok && !should_abort()) emit_event("fault", phase, run.run_id, "fail", parameters, metrics); + if (!metrics[0]) + snprintf(metrics, sizeof(metrics), "{\"operations_or_bytes\":%" PRIu64 ",\"bytes_rx\":%" PRIu64 "}", a, b); + if ((!ok && !should_abort()) || fixture_fault) emit_event("fault", phase, run.run_id, "fail", parameters, metrics); emit_event("phase_end", phase, run.run_id, result, parameters, metrics); xSemaphoreTake(state_lock, portMAX_DELAY); db_run_finish(¤t_run, result, uptime_ms()); + status_sync_locked(); xSemaphoreGive(state_lock); emit_event("run_complete", phase, run.run_id, result, parameters, metrics); if (run.request.workload == DB_CONTROLLED_REBOOT && ok && !should_abort()) { @@ -332,16 +387,60 @@ static esp_err_t send_json(httpd_req_t *req, cJSON *root, int status) { return err; } +// Where each quantity of interest comes from. "dut." is measured +// by this firmware, "derived." is computed from DUT data, and "external" +// is owned by bench instruments; DragonBench reports no value for it. Each +// experiment profile adds only what it actually acquires or needs. +static void add_measurement_provenance(cJSON *parent) { + cJSON *m = cJSON_AddObjectToObject(parent, "measurement_provenance"); + cJSON_AddStringToObject(m, "soc_temperature", "dut.esp32s3_temperature_sensor"); + cJSON_AddStringToObject(m, "wifi_rssi", "dut.esp32s3_wifi"); + cJSON_AddStringToObject(m, "supply_voltage", "external"); + cJSON_AddStringToObject(m, "supply_current", "external"); + cJSON_AddStringToObject(m, "rail_voltage", "external"); + cJSON_AddStringToObject(m, "reset_and_brownout", "external"); +#if DB_EXPERIMENT_FAN_CHARACTERIZATION + cJSON_AddStringToObject(m, "fan_tach_edges", "dut.esp32s3_pcnt"); + cJSON_AddStringToObject(m, "fan_speed", + fan_characterization_ppr() ? "derived.fan_tach_edges_and_configured_ppr" : "external"); + cJSON_AddStringToObject(m, "fan_pwm_line_waveform", "external"); + cJSON_AddStringToObject(m, "fan_supply_voltage", "external"); + cJSON_AddStringToObject(m, "fan_supply_current", "external"); + cJSON_AddStringToObject(m, "fan_airflow", "external"); + cJSON_AddStringToObject(m, "fan_temperature", "external"); +#endif +} + +// Revision this image was built from; empty SHA / "unknown" when the build +// could not establish it (see build_provenance.cmake). +static void add_build(cJSON *parent) { + cJSON *b = cJSON_AddObjectToObject(parent, "build"); + cJSON_AddStringToObject(b, "firmware_version", CONFIG_DB_FIRMWARE_VERSION); + if (DB_BUILD_GIT_SHA[0]) cJSON_AddStringToObject(b, "git_sha", DB_BUILD_GIT_SHA); + else cJSON_AddNullToObject(b, "git_sha"); + cJSON_AddStringToObject(b, "source_tree", DB_BUILD_SOURCE_TREE); + cJSON_AddStringToObject(b, "esp_idf", esp_get_idf_version()); +} + static cJSON *identity_json(void) { cJSON *o = cJSON_CreateObject(); cJSON_AddStringToObject(o, "product", "DragonBench"); cJSON_AddStringToObject(o, "target", CONFIG_DB_TARGET_NAME); + cJSON_AddStringToObject(o, "board_profile", CONFIG_DB_TARGET_NAME); + cJSON_AddStringToObject(o, "experiment_profile", DB_EXPERIMENT_PROFILE_NAME); cJSON_AddStringToObject(o, "firmware_version", CONFIG_DB_FIRMWARE_VERSION); + add_build(o); cJSON_AddStringToObject(o, "device_id", device_id); cJSON_AddStringToObject(o, "image_class", "characterization"); + // Product actuator capabilities are absent in every build, fixture included. cJSON_AddBoolToObject(o, "heater_capability", false); cJSON_AddBoolToObject(o, "fan_control_capability", false); - cJSON_AddStringToObject(o, "measurement_authority", "external_bench_equipment"); + cJSON *bench = cJSON_AddObjectToObject(o, "bench_stimulus"); + cJSON_AddBoolToObject(bench, "fan_pwm_fixture", DB_EXPERIMENT_FAN_CHARACTERIZATION); +#if DB_EXPERIMENT_FAN_CHARACTERIZATION + fan_characterization_describe(bench); +#endif + add_measurement_provenance(o); return o; } @@ -403,6 +502,11 @@ static esp_err_t sensors_get(httpd_req_t *req) { sta_state == DB_STA_CONNECTED); add_sensor(a, "supply_voltage", "MCU supply voltage", "V", "none", "unsupported", 0, false); add_sensor(a, "supply_current", "MCU supply current", "A", "none", "unsupported", 0, false); +#if DB_EXPERIMENT_FAN_CHARACTERIZATION + // Counted per FAN_PWM_HOLD window and reported in its phase_end metrics. + add_sensor(a, "fan_tach_edges", "Fan tach falling edges", "edges", "pcnt", + fan_characterization_tach_ready() ? "available" : "unavailable", 0, false); +#endif return send_json(req, root, 200); } @@ -412,6 +516,10 @@ static esp_err_t workloads_get(httpd_req_t *req) { cJSON *o = cJSON_CreateObject(); cJSON_AddStringToObject(o, "id", db_workload_name((db_workload_t)i)); cJSON_AddStringToObject(o, "status", db_workload_supported((db_workload_t)i) ? "available" : "unsupported"); + if (i == DB_FAN_PWM_HOLD) { + cJSON_AddStringToObject(o, "class", "bench_stimulus"); + if (!db_workload_supported(DB_FAN_PWM_HOLD)) cJSON_AddStringToObject(o, "reason", "requires fan-characterization experiment profile"); + } cJSON_AddItemToArray(a, o); } static const char *const unsupported[] = { @@ -446,6 +554,8 @@ static esp_err_t runs_post(httpd_req_t *req) { cJSON *duration = body ? cJSON_GetObjectItemCaseSensitive(body, "duration_ms") : NULL; db_run_request_t request = {.duration_ms = 30000}; bool parsed = cJSON_IsString(w) && db_workload_parse(w->valuestring, &request.workload); + // A bench stimulus never runs for an implied duration. + if (request.workload == DB_FAN_PWM_HOLD && !cJSON_IsNumber(duration)) request.duration_ms = 0; if (cJSON_IsNumber(duration)) { if (duration->valuedouble < 1 || duration->valuedouble > 3600000 || duration->valuedouble != (double)(uint32_t)duration->valuedouble) parsed = false; @@ -465,8 +575,23 @@ static esp_err_t runs_post(httpd_req_t *req) { rate->valuedouble != (double)(uint32_t)rate->valuedouble) parsed = false; else request.rate_bps = (uint32_t)rate->valuedouble; } + cJSON *pwm_hz = body ? cJSON_GetObjectItemCaseSensitive(body, "pwm_hz") : NULL; + cJSON *sink_duty = body ? cJSON_GetObjectItemCaseSensitive(body, "sink_duty_pct") : NULL; + const char *field_error = NULL; + if (pwm_hz) { + request.pwm_hz_set = true; + if (!cJSON_IsNumber(pwm_hz) || pwm_hz->valuedouble < 0 || pwm_hz->valuedouble > UINT32_MAX || + pwm_hz->valuedouble != (double)(uint32_t)pwm_hz->valuedouble) field_error = "pwm_hz must be an integer"; + else request.pwm_hz = (uint32_t)pwm_hz->valuedouble; + } + if (sink_duty) { + request.sink_duty_set = true; + if (!cJSON_IsNumber(sink_duty) || !db_fan_sink_duty_from_pct(sink_duty->valuedouble, &request.sink_duty_tenths_pct)) + field_error = "sink_duty_pct must be 0..100 in 0.1 steps"; + } char validation[96] = "invalid workload"; - bool valid = parsed && db_request_validate(&request, validation, sizeof(validation)); + if (field_error) snprintf(validation, sizeof(validation), "%s", field_error); + bool valid = parsed && !field_error && db_request_validate(&request, validation, sizeof(validation)); cJSON_Delete(body); if (!valid) { cJSON *o = cJSON_CreateObject(); cJSON_AddStringToObject(o, "error", validation); return send_json(req, o, 400); } xSemaphoreTake(state_lock, portMAX_DELAY); @@ -476,9 +601,10 @@ static esp_err_t runs_post(httpd_req_t *req) { char id[DB_RUN_ID_LEN]; snprintf(id, sizeof(id), "%08" PRIx32 "-%08" PRIx32, boot_nonce, ++run_counter); db_run_begin(¤t_run, &request, id, uptime_ms()); + status_sync_locked(); // off before the workload task can emit phase_start xSemaphoreGive(state_lock); if (xTaskCreate(workload_task, "db_workload", 8192, NULL, 5, NULL) != pdPASS) { - xSemaphoreTake(state_lock, portMAX_DELAY); db_run_finish(¤t_run, "fail", uptime_ms()); xSemaphoreGive(state_lock); + xSemaphoreTake(state_lock, portMAX_DELAY); db_run_finish(¤t_run, "fail", uptime_ms()); status_sync_locked(); xSemaphoreGive(state_lock); cJSON *o = cJSON_CreateObject(); cJSON_AddStringToObject(o, "error", "task creation failed"); return send_json(req, o, 409); } cJSON *o = cJSON_CreateObject(); cJSON_AddStringToObject(o, "run_id", id); cJSON_AddStringToObject(o, "state", "running"); @@ -538,7 +664,7 @@ static void snapshot_events_locked(event_snapshot_t *snapshot) { } static esp_err_t events_get(httpd_req_t *req) { - // Bounded: DB_EVENT_CAPACITY * DB_EVENT_JSON_LEN (~32 KiB), too large for + // Bounded: DB_EVENT_CAPACITY * DB_EVENT_JSON_LEN (40 KiB), too large for // the httpd task stack; released before returning on every path. event_snapshot_t *snapshot = malloc(sizeof(*snapshot)); if (!snapshot) { @@ -648,7 +774,7 @@ static const char landing[] = "
" "
" "

DragonBench characterization image

" -"

No product actuator support. This image cannot drive a heater or fan. All voltage, current, and rail evidence remains owned by external bench equipment.

" +"

No product actuator support. This image has no heater or product fan-control path. The fan-characterization experiment profile can only drive an external open-drain PWM stimulus stage for characterization, with no closed loop or thresholds. All voltage, current, and rail evidence remains owned by external bench equipment.

" "
" "
" "
" @@ -659,6 +785,10 @@ static const char landing[] = " Fan-control capability" " ABSENT" "
" +"
" +" Bench fan-PWM stimulus" +" ABSENT" +"
" "
" "" "
" @@ -723,11 +853,14 @@ static const char landing[] = " var net=data.network_connected;" " grid.appendChild(metricTile('Network',net===true?'connected':net===false?'disconnected':undefined,net===true?'connected':net===false?'disconnected':undefined));" " grid.appendChild(metricTile('Run ID',data.run_id));" -" grid.appendChild(metricTile('Measurement authority',data.measurement_authority));" +" grid.appendChild(metricTile('Experiment profile',data.experiment_profile));" " var heater=role('capability-heater'),fan=role('capability-fan');" " if(typeof data.heater_capability==='boolean'){heater.textContent=data.heater_capability?'PRESENT':'ABSENT';heater.dataset.status=data.heater_capability?'error':'unsupported'}" " if(typeof data.fan_control_capability==='boolean'){fan.textContent=data.fan_control_capability?'PRESENT':'ABSENT';fan.dataset.status=data.fan_control_capability?'error':'unsupported'}" -" role('identity').textContent=(data.target||'target —')+' · firmware '+(data.firmware_version||'—');" +" var stimulus=role('capability-fan-stimulus'),bench=data.bench_stimulus;" +" if(bench&&typeof bench.fan_pwm_fixture==='boolean'){stimulus.textContent=bench.fan_pwm_fixture?'BENCH FIXTURE':'ABSENT';stimulus.dataset.status=bench.fan_pwm_fixture?'running':'unsupported'}" +" var build=data.build||{},sha=typeof build.git_sha==='string'?build.git_sha.slice(0,12):'unknown';" +" role('identity').textContent=(data.board_profile||data.target||'target —')+' · '+(data.experiment_profile||'profile —')+' · firmware '+(data.firmware_version||'—')+' · '+sha+(build.source_tree==='dirty'?' (dirty)':'');" " }" " function renderSensors(data){" " var list=role('sensor-fields');clear(list);" @@ -1154,6 +1287,10 @@ static void antenna_init(void) { } void app_main(void) { +#if DB_EXPERIMENT_FAN_CHARACTERIZATION + fan_characterization_boot(); // release the stimulus line before anything else +#endif + status_rgb_init(); // off until the device is ready ESP_ERROR_CHECK(nvs_flash_init()); state_lock = xSemaphoreCreateMutex(); boot_nonce = esp_random(); @@ -1170,7 +1307,13 @@ void app_main(void) { char reset_metrics[64]; snprintf(reset_metrics, sizeof(reset_metrics), "{\"reason\":\"%s\"}", reset_reason_text); const char *prior_run = previous_reboot_run_id[0] ? previous_reboot_run_id : NULL; - emit_event("boot", NULL, prior_run, NULL, NULL, NULL); + // Image identity in the first event, so a serial log alone names what ran. + char image[192]; + snprintf(image, sizeof(image), + "{\"board_profile\":\"%s\",\"experiment_profile\":\"%s\",\"git_sha\":%s%s%s,\"source_tree\":\"%s\"}", + CONFIG_DB_TARGET_NAME, DB_EXPERIMENT_PROFILE_NAME, DB_BUILD_GIT_SHA[0] ? "\"" : "", + DB_BUILD_GIT_SHA[0] ? DB_BUILD_GIT_SHA : "null", DB_BUILD_GIT_SHA[0] ? "\"" : "", DB_BUILD_SOURCE_TREE); + emit_event("boot", NULL, prior_run, NULL, image, NULL); emit_event("reset_reason", NULL, prior_run, NULL, NULL, reset_metrics); antenna_init(); if (!start_wifi()) { @@ -1200,4 +1343,8 @@ void app_main(void) { else ESP_LOGW(TAG, "mDNS unavailable (%s); use AP IP", esp_err_to_name(mdns_result)); start_http(); emit_event("ready", NULL, prior_run, NULL, NULL, NULL); + xSemaphoreTake(state_lock, portMAX_DELAY); + device_ready = true; + status_sync_locked(); + xSemaphoreGive(state_lock); } diff --git a/firmware/targets/esp32s3/main/status_rgb.c b/firmware/targets/esp32s3/main/status_rgb.c new file mode 100644 index 0000000..5f3215e --- /dev/null +++ b/firmware/targets/esp32s3/main/status_rgb.c @@ -0,0 +1,101 @@ +#include "status_rgb.h" + +#include "sdkconfig.h" + +#if CONFIG_DB_STATUS_RGB_GPIO >= 0 + +#include + +#include "driver/gpio.h" +#include "driver/rmt_tx.h" +#include "esp_log.h" +#include "esp_rom_sys.h" + +#define TAG "status_rgb" +// 10 MHz: 0.1 us per RMT tick. +#define RMT_RESOLUTION_HZ 10000000 +// WS2812 bit timing as in the ESP-IDF RMT led_strip example: a 0 is 0.3 us +// high then 0.9 us low, a 1 is 0.9 us high then 0.3 us low. +#define SHORT_TICKS 3 +#define LONG_TICKS 9 +// Low time after a frame before the LED shows it; WS2812B V5 needs > 280 us. +#define LATCH_US 300 +// Time for a GPIO-powered LED to start after its supply is switched on. +#define POWER_UP_US 1000 +// Green channel level (of 255): visible, and a small fixed load. +#define GREEN_LEVEL 16 + +static rmt_channel_handle_t channel; +static rmt_encoder_handle_t encoder; +static bool available; + +static void set_power(bool on) { +#if CONFIG_DB_STATUS_RGB_POWER_GPIO >= 0 + const int active = CONFIG_DB_STATUS_RGB_POWER_ACTIVE_LEVEL; + gpio_set_level(CONFIG_DB_STATUS_RGB_POWER_GPIO, on ? active : !active); +#else + (void)on; +#endif +} + +// Sends one GRB frame and waits until the LED has latched it. +static void send_frame(uint8_t green) { + const uint8_t grb[3] = {green, 0, 0}; + const rmt_transmit_config_t tx = {.loop_count = 0, .flags.eot_level = 0}; + if (rmt_transmit(channel, encoder, grb, sizeof(grb), &tx) == ESP_OK) + rmt_tx_wait_all_done(channel, 10); + esp_rom_delay_us(LATCH_US); +} + +void status_rgb_init(void) { +#if CONFIG_DB_STATUS_RGB_POWER_GPIO >= 0 + gpio_reset_pin(CONFIG_DB_STATUS_RGB_POWER_GPIO); + gpio_set_direction(CONFIG_DB_STATUS_RGB_POWER_GPIO, GPIO_MODE_OUTPUT); + set_power(false); +#endif + const rmt_tx_channel_config_t config = { + .gpio_num = CONFIG_DB_STATUS_RGB_GPIO, + .clk_src = RMT_CLK_SRC_DEFAULT, + .resolution_hz = RMT_RESOLUTION_HZ, + .mem_block_symbols = 48, // one frame is 24 symbols + .trans_queue_depth = 1, + }; + const rmt_bytes_encoder_config_t bits = { + .bit0 = {.level0 = 1, .duration0 = SHORT_TICKS, .level1 = 0, .duration1 = LONG_TICKS}, + .bit1 = {.level0 = 1, .duration0 = LONG_TICKS, .level1 = 0, .duration1 = SHORT_TICKS}, + .flags.msb_first = 1, + }; + available = rmt_new_tx_channel(&config, &channel) == ESP_OK && + rmt_new_bytes_encoder(&bits, &encoder) == ESP_OK && rmt_enable(channel) == ESP_OK; + if (!available) { + ESP_LOGW(TAG, "status RGB unavailable on GPIO%d", CONFIG_DB_STATUS_RGB_GPIO); + return; + } +#if CONFIG_DB_STATUS_RGB_POWER_GPIO < 0 + // Always powered: blank it. A GPIO-powered LED is already unpowered, and + // clocking data into it would feed it through its data input. + send_frame(0); +#endif +} + +void status_rgb_set(bool green) { + if (!available) return; + if (green) { + set_power(true); +#if CONFIG_DB_STATUS_RGB_POWER_GPIO >= 0 + esp_rom_delay_us(POWER_UP_US); +#endif + send_frame(GREEN_LEVEL); + } else { + // Dark first, so the data line is idle low before the supply drops. + send_frame(0); + set_power(false); + } +} + +#else + +void status_rgb_init(void) {} +void status_rgb_set(bool green) { (void)green; } + +#endif diff --git a/firmware/targets/esp32s3/main/status_rgb.h b/firmware/targets/esp32s3/main/status_rgb.h new file mode 100644 index 0000000..f16c23f --- /dev/null +++ b/firmware/targets/esp32s3/main/status_rgb.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +// The board's onboard WS2812 LED as DragonBench's status light: solid green or +// off, nothing else. Both calls block until the LED has latched the new state. +// Without CONFIG_DB_STATUS_RGB_GPIO they do nothing. + +// Configures the LED pins and turns the LED off. Call once, at boot. +void status_rgb_init(void); +// Solid green when true; off (and unpowered, where the board can) when false. +void status_rgb_set(bool green); diff --git a/protocol/event.schema.json b/protocol/event.schema.json index 5268df7..870283e 100644 --- a/protocol/event.schema.json +++ b/protocol/event.schema.json @@ -13,7 +13,8 @@ "result": {"enum": ["pass", "fail", "aborted", "unsupported"]}, "parameters": {"type": "object"}, "metrics": {"type": "object"}, - "target": {"enum": ["esp32s3-n8r8", "esp32s3-tinys3d"]}, + "truncated": {"const": true, "description": "Event exceeded the device event slot; parameters and metrics were dropped from /api/v1/events (the serial log keeps the full line)"}, + "target": {"enum": ["esp32s3-n8r8", "esp32s3-n16r8", "esp32s3-tinys3d"]}, "firmware_version": {"type": "string"} }, "additionalProperties": true diff --git a/protocol/openapi.yaml b/protocol/openapi.yaml index 62f9e55..27598b1 100644 --- a/protocol/openapi.yaml +++ b/protocol/openapi.yaml @@ -4,16 +4,118 @@ info: version: 0.1.0 paths: /api/v1/device: - get: {summary: Device identity and immutable product boundary} + get: + summary: Device identity, image provenance, and immutable product boundary + description: >- + An image is one board profile plus one experiment profile. + board_profile (same value as target) and experiment_profile name them; + build.git_sha and build.source_tree record the source revision when + the build could establish it (git_sha null and source_tree "unknown" + otherwise; "dirty" means tracked changes or untracked, non-ignored + files). measurement_provenance maps each quantity of interest to its + source: "dut." is measured by this firmware, + "derived." is computed from DUT data, and "external" is owned by + bench instruments and never reported by DragonBench. Experiment + profiles add only the quantities they acquire or depend on. + heater_capability and fan_control_capability are false in every + build: DragonBench never implements product heater or fan control, + closed loops, thresholds, or safety policy. + bench_stimulus.fan_pwm_fixture is true only in the + fan-characterization experiment profile, which can drive an external + open-drain stage that sinks a fan PWM line; bench_stimulus.fan_fixture + then reports its configured wiring and live line_state. + responses: + "200": + description: Identity + content: + application/json: + schema: + type: object + required: [board_profile, experiment_profile, firmware_version, build, device_id, measurement_provenance] + properties: + target: {type: string} + board_profile: {type: string, enum: [esp32s3-n8r8, esp32s3-n16r8, esp32s3-tinys3d]} + experiment_profile: {type: string, enum: [baseline, fan-characterization]} + firmware_version: {type: string} + build: + type: object + required: [firmware_version, git_sha, source_tree, esp_idf] + properties: + firmware_version: {type: string} + git_sha: {type: string, nullable: true, pattern: "^[0-9a-f]+$"} + source_tree: {type: string, enum: [clean, dirty, unknown]} + esp_idf: {type: string} + device_id: {type: string} + measurement_provenance: + type: object + additionalProperties: {type: string, pattern: "^(external|dut\\.[a-z0-9_]+|derived\\.[a-z0-9_]+)$"} + example: + soc_temperature: dut.esp32s3_temperature_sensor + supply_current: external + fan_tach_edges: dut.esp32s3_pcnt + fan_speed: external + heater_capability: {type: boolean, enum: [false]} + fan_control_capability: {type: boolean, enum: [false]} + bench_stimulus: + type: object + required: [fan_pwm_fixture] + properties: + fan_pwm_fixture: {type: boolean} + fan_fixture: + type: object + description: Present only in the fan-characterization experiment profile + properties: + stimulus: {type: string, enum: [pwm_sink_via_external_open_drain_stage]} + fan_response: {type: string, enum: [uncharacterized]} + line_state: {type: string, enum: [unconfigured, released, static_sink, pwm, release_failed, drive_failed]} + pwm_gate_gpio: {type: integer} + gate_sink_level: {type: integer, enum: [0, 1]} + tach_gpio: {type: integer} + tach_edge: {type: string, enum: [falling]} + tach_ready: {type: boolean} + tach_ppr: {type: integer, nullable: true} /api/v1/status: get: {summary: Current run, network, uptime, and reset state} /api/v1/sensors: - get: {summary: Truthful DUT signal registry} + get: + summary: Truthful DUT signal registry + description: >- + The fan-characterization profile adds fan_tach_edges (source pcnt, + no instantaneous value: edges are counted per FAN_PWM_HOLD window). /api/v1/workloads: - get: {summary: Supported and unsupported workload registry} + get: + summary: Supported and unsupported workload registry + description: >- + FAN_PWM_HOLD is listed with class bench_stimulus. It is "available" + only in the fan-characterization experiment profile and + "unsupported" (reason "requires fan-characterization experiment + profile") otherwise. /api/v1/runs: post: summary: Start one validated workload + description: >- + FAN_PWM_HOLD (fan-characterization experiment profile only) makes the + external stage sink the fan PWM line for sink_duty_pct of the time, + for duration_ms, while PCNT counts tach falling edges. 0 % holds the + gate at its release level and 100 % holds it at its sink level, both + statically; only values strictly between use PWM at pwm_hz. pwm_hz is + required and validated for every request so a sweep is uniform, but no + PWM is generated at the static endpoints. pwm_hz or sink_duty_pct on + any other workload is rejected. The run is refused before any + actuation if the line cannot be released or tach capture is not + ready. The line is released at boot, at run end, on abort, and on + every error. No sink duty, including 0 and 100, and the released + state are asserted to mean stopped or full speed. + phase_start/phase_end parameters carry pwm_hz, sink_duty_pct, + duration_ms, stimulus (static_release, pwm, static_sink), + applied_sink_duty_pct, and, for pwm, duty_resolution_bits; phase_end + metrics carry tach_edges, window_start_us, window_us, edge_hz, ppr, + rpm, and released_us (esp_timer microseconds since boot). ppr and rpm + are null unless a PPR was configured with evidence. fixture_error is + one of invalid_parameters, pre_release_failed, tach_unavailable, + actuation_failed, tach_clear_failed, tach_read_failed, or + release_failed; a failed release is also "released": false with a + fault event. requestBody: required: true content: @@ -27,6 +129,8 @@ paths: host: {type: string, maxLength: 253} port: {type: integer, minimum: 1, maximum: 65535} rate_bps: {type: integer, minimum: 0} + pwm_hz: {type: integer, minimum: 10, maximum: 50000, description: "FAN_PWM_HOLD only. Fixture generator range, not a fan specification"} + sink_duty_pct: {type: number, minimum: 0, maximum: 100, multipleOf: 0.1, description: "FAN_PWM_HOLD only. Percent of each period the external stage sinks the fan PWM line"} /api/v1/runs/{id}: get: summary: Read one run diff --git a/sdkconfig.defaults b/sdkconfig.defaults index d8f36db..dd6ac61 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -17,3 +17,9 @@ CONFIG_MDNS_MAX_INTERFACES=2 CONFIG_MDNS_PREDEF_NETIF_AP=y CONFIG_MDNS_PREDEF_NETIF_STA=y # CONFIG_MDNS_PREDEF_NETIF_ETH is not set +# N8R8 (YD-ESP32-S3) and N16R8 boards: WS2812 status RGB LED data on GPIO48, +# no power GPIO. Board-owned: status light only, never fixture wiring. +CONFIG_DB_STATUS_RGB_GPIO=48 +# Experiment profile: baseline unless an sdkconfig.defaults. +# overlay selects exactly one other (docs/EXPERIMENT_PROFILES.md). +CONFIG_DB_EXPERIMENT_BASELINE=y diff --git a/sdkconfig.defaults.fan-characterization b/sdkconfig.defaults.fan-characterization new file mode 100644 index 0000000..fe9ffea --- /dev/null +++ b/sdkconfig.defaults.fan-characterization @@ -0,0 +1,9 @@ +# Experiment profile: fan-characterization. Bench stimulus and tach capture +# through an EXTERNAL open-drain stage. NOT product fan control. +# Layer after the board profile, then add a local, untracked overlay that +# states the wired fixture. Every fixture setting defaults to unset, so a build +# without that overlay fails with an #error naming the missing setting: +# idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.tinys3d;sdkconfig.defaults.fan-characterization;sdkconfig.fan-characterization.local" build +# See docs/FAN_CHARACTERIZATION.md for what the local overlay must contain. +# CONFIG_DB_EXPERIMENT_BASELINE is not set +CONFIG_DB_EXPERIMENT_FAN_CHARACTERIZATION=y diff --git a/sdkconfig.defaults.n16r8 b/sdkconfig.defaults.n16r8 new file mode 100644 index 0000000..baa9e46 --- /dev/null +++ b/sdkconfig.defaults.n16r8 @@ -0,0 +1,12 @@ +# ESP32-S3-WROOM-1 module marked MCN16R8 (16 MB flash, 8 MB Octal SPI PSRAM) +# on a Lonely Binary pluggable-terminal carrier. Octal PSRAM at 80 MHz, no +# RF-switch GPIO, and the GPIO48 status RGB LED (silkscreen RGB@IO48) come +# from sdkconfig.defaults; this board profile changes only the flash size and +# the reported identity. +# partitions.csv is unchanged and ends below 8 MB, so the upper 8 MB of the +# physical flash is intentionally unused (docs/TARGET_N16R8.md). +# Build with: +# idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.n16r8" build +CONFIG_DB_TARGET_NAME="esp32s3-n16r8" +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +# CONFIG_ESPTOOLPY_FLASHSIZE_8MB is not set diff --git a/sdkconfig.defaults.tinys3d b/sdkconfig.defaults.tinys3d index 89ed2c9..e545bdf 100644 --- a/sdkconfig.defaults.tinys3d +++ b/sdkconfig.defaults.tinys3d @@ -4,6 +4,11 @@ # idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.tinys3d" build CONFIG_DB_TARGET_NAME="esp32s3-tinys3d" CONFIG_DB_RF_SWITCH_GPIO=38 +# WS2812B status RGB LED (schematic Rev D-P1, LED2): data GPIO18, VDD supplied +# directly by GPIO17 (high powers it). +CONFIG_DB_STATUS_RGB_GPIO=18 +CONFIG_DB_STATUS_RGB_POWER_GPIO=17 +CONFIG_DB_STATUS_RGB_POWER_ACTIVE_LEVEL=1 CONFIG_SPIRAM_MODE_QUAD=y # CONFIG_SPIRAM_MODE_OCT is not set CONFIG_SPIRAM_SPEED_40M=y diff --git a/tests/native/README.md b/tests/native/README.md index dda5d37..08b8623 100644 --- a/tests/native/README.md +++ b/tests/native/README.md @@ -1,14 +1,26 @@ # Native common-state test -This test directly exercises the platform-neutral C workload registry, -validation, state transitions, run identity retention, and abort behavior. +`test_db_run.c` directly exercises the platform-neutral C workload registry, +validation, state transitions, run identity retention, abort behavior, and the +status-light policy (`db_status_ready`). +`test_db_fan.c` covers the fan-characterization logic: parameter bounds, the +static-release / PWM / static-sink stimulus plan, the sink fraction the line +sees for both gate polarities, tach readiness before actuation, tach +edge-frequency and RPM arithmetic, unknown PPR, the event-size budget, and +release of the stimulus line on boot, run end, abort, and every error path. +Both are built twice: as the baseline experiment profile and with +`-DCONFIG_DB_EXPERIMENT_FAN_CHARACTERIZATION=1`. From the repository root on a host with a C11 compiler: ```text -cc -std=c11 -Wall -Wextra -Werror -Ifirmware/common/include \ - firmware/common/db_run.c tests/native/test_db_run.c -o test_db_run -./test_db_run +for defines in "" "-DCONFIG_DB_EXPERIMENT_FAN_CHARACTERIZATION=1"; do + for test in test_db_run test_db_fan; do + cc -std=c11 -Wall -Wextra -Werror $defines -Ifirmware/common/include \ + firmware/common/db_run.c firmware/common/db_network.c firmware/common/db_fan.c \ + tests/native/$test.c -o $test && ./$test + done +done ``` The generated executable is ignored by Git. diff --git a/tests/native/test_db_fan.c b/tests/native/test_db_fan.c new file mode 100644 index 0000000..7f9644d --- /dev/null +++ b/tests/native/test_db_fan.c @@ -0,0 +1,479 @@ +// Fan-characterization common logic. Built twice: as the baseline experiment +// profile, and with -DCONFIG_DB_EXPERIMENT_FAN_CHARACTERIZATION=1. + +#include "db_fan.h" +#include "db_run.h" + +#include +#include +#include +#include + +// Models the gate pad and the external stage: which level the pad sits at +// (static) or which PWM it carries, and every hardware call in order. +typedef struct { + int gate_sink_level; + int pad_level; // -1: never driven + bool pwm_active; + uint32_t pwm_hz, pwm_bits, pwm_counts; + bool pwm_invert; + int64_t clock_us; + int drive_calls; + int failing_drive_call; // 0: never + int abort_after_polls; // <0: never + int polls; + bool tach_available; + bool fail_pwm; + bool fail_tach_clear; + bool fail_tach_read; + int64_t tach_edges_per_ms; + int64_t tach_origin_us; + char log[256]; +} fake_fixture_t; + +static void note(fake_fixture_t *fake, const char *entry) { + strncat(fake->log, entry, sizeof(fake->log) - strlen(fake->log) - 1); +} + +static bool fake_drive_static(void *ctx, int gate_level) { + fake_fixture_t *fake = ctx; + ++fake->drive_calls; + note(fake, gate_level ? "S1 " : "S0 "); + if (fake->failing_drive_call == fake->drive_calls) return false; + fake->pwm_active = false; + fake->pad_level = gate_level; + return true; +} + +static bool fake_start_pwm(void *ctx, uint32_t pwm_hz, uint32_t bits, uint32_t counts, bool invert) { + fake_fixture_t *fake = ctx; + note(fake, "PWM "); + // Mirrors the ESP32-S3 LEDC constraint the planner must respect. + assert(counts > 0 && counts < (1U << bits)); + fake->pwm_active = true; // a failed configuration may already drive the pad + if (fake->fail_pwm) return false; + fake->pwm_hz = pwm_hz; + fake->pwm_bits = bits; + fake->pwm_counts = counts; + fake->pwm_invert = invert; + return true; +} + +static bool fake_tach_ready(void *ctx) { + fake_fixture_t *fake = ctx; + note(fake, "READY "); + return fake->tach_available; +} + +static bool fake_tach_clear(void *ctx) { + fake_fixture_t *fake = ctx; + note(fake, "CLEAR "); + fake->tach_origin_us = fake->clock_us; + return !fake->fail_tach_clear; +} + +static bool fake_tach_read(void *ctx, int64_t *edges) { + fake_fixture_t *fake = ctx; + note(fake, "READ "); + if (fake->fail_tach_read) return false; + *edges = (fake->clock_us - fake->tach_origin_us) / 1000 * fake->tach_edges_per_ms; + return true; +} + +static int64_t fake_now(void *ctx) { return ((fake_fixture_t *)ctx)->clock_us; } + +static bool fake_should_abort(void *ctx) { + fake_fixture_t *fake = ctx; + return fake->abort_after_polls >= 0 && fake->polls++ >= fake->abort_after_polls; +} + +static void fake_sleep(void *ctx, uint32_t ms) { ((fake_fixture_t *)ctx)->clock_us += (int64_t)ms * 1000; } + +static const db_fan_ops_t fake_ops_table = {fake_drive_static, fake_start_pwm, fake_tach_ready, fake_tach_clear, + fake_tach_read, fake_now, fake_should_abort, fake_sleep, NULL}; + +typedef struct { + fake_fixture_t fake; + db_fan_ops_t ops; + db_fan_fixture_t fixture; +} fake_bench_t; + +static void fake_bench(fake_bench_t *bench, int gate_sink_level) { + memset(bench, 0, sizeof(*bench)); + bench->fake.gate_sink_level = gate_sink_level; + bench->fake.pad_level = -1; + bench->fake.abort_after_polls = -1; + bench->fake.clock_us = 5000000; + bench->fake.tach_available = true; + bench->ops = fake_ops_table; + bench->ops.ctx = &bench->fake; + bench->fixture.ops = &bench->ops; + bench->fixture.gate_sink_level = gate_sink_level; +} + +// Fraction of time, in milli-percent, the modeled stage sinks the fan line. +static uint32_t line_sink_milli_pct(const fake_fixture_t *fake) { + if (!fake->pwm_active) return fake->pad_level == fake->gate_sink_level ? 100000U : 0U; + const uint64_t full = 1ULL << fake->pwm_bits; + const uint64_t pad_high = fake->pwm_invert ? full - fake->pwm_counts : fake->pwm_counts; + const uint64_t sinking = fake->gate_sink_level ? pad_high : full - pad_high; + return (uint32_t)((sinking * 100000U + full / 2U) / full); +} + +static bool line_released(const fake_fixture_t *fake) { + return !fake->pwm_active && fake->pad_level == (fake->gate_sink_level ? 0 : 1); +} + +static db_run_request_t fan_request(uint32_t pwm_hz, uint16_t tenths, uint32_t duration_ms) { + db_run_request_t request = {.workload = DB_FAN_PWM_HOLD, .duration_ms = duration_ms}; + request.pwm_hz_set = request.sink_duty_set = true; + request.pwm_hz = pwm_hz; + request.sink_duty_tenths_pct = tenths; + return request; +} + +static void test_capability_gate(void) { + char error[96]; + db_workload_t workload = DB_WORKLOAD_COUNT; + assert(db_workload_parse("FAN_PWM_HOLD", &workload) && workload == DB_FAN_PWM_HOLD); + assert(db_workload_supported(DB_FAN_PWM_HOLD) == (DB_EXPERIMENT_FAN_CHARACTERIZATION == 1)); + db_run_request_t request = fan_request(25000, 500, 1000); +#if DB_EXPERIMENT_FAN_CHARACTERIZATION + assert(db_request_validate(&request, error, sizeof(error))); +#else + assert(!db_request_validate(&request, error, sizeof(error))); + assert(strcmp(error, "FAN_PWM_HOLD requires the fan-characterization experiment profile") == 0); + // Every other workload stays available in the baseline profile. + for (int i = 0; i < DB_WORKLOAD_COUNT; ++i) + assert(db_workload_supported((db_workload_t)i) == (i != DB_FAN_PWM_HOLD)); +#endif + // Fan parameters never ride along on another workload, in either build. + db_run_request_t cpu = {.workload = DB_CPU_STRESS, .duration_ms = 1000, .pwm_hz_set = true, .pwm_hz = 25000}; + assert(!db_request_validate(&cpu, error, sizeof(error))); + assert(strstr(error, "only to FAN_PWM_HOLD")); + cpu.pwm_hz_set = false; + cpu.sink_duty_set = true; + assert(!db_request_validate(&cpu, error, sizeof(error))); + cpu.sink_duty_set = false; + assert(db_request_validate(&cpu, error, sizeof(error))); +} + +static void test_parameter_bounds(void) { +#if DB_EXPERIMENT_FAN_CHARACTERIZATION + char error[96]; + db_run_request_t request = fan_request(DB_FAN_PWM_HZ_MIN, 0, 1); + assert(db_request_validate(&request, error, sizeof(error))); + request = fan_request(DB_FAN_PWM_HZ_MAX, 1000, 3600000); + assert(db_request_validate(&request, error, sizeof(error))); + request = fan_request(DB_FAN_PWM_HZ_MIN - 1, 500, 1000); + assert(!db_request_validate(&request, error, sizeof(error)) && strstr(error, "pwm_hz")); + request = fan_request(DB_FAN_PWM_HZ_MAX + 1, 500, 1000); + assert(!db_request_validate(&request, error, sizeof(error)) && strstr(error, "pwm_hz")); + request = fan_request(25000, 1001, 1000); + assert(!db_request_validate(&request, error, sizeof(error)) && strstr(error, "sink_duty_pct")); + request = fan_request(25000, 500, 0); + assert(!db_request_validate(&request, error, sizeof(error)) && strstr(error, "duration_ms")); + request = fan_request(25000, 500, 3600001); + assert(!db_request_validate(&request, error, sizeof(error)) && strstr(error, "duration_ms")); + request = fan_request(25000, 500, 1000); + request.pwm_hz_set = false; + assert(!db_request_validate(&request, error, sizeof(error)) && strstr(error, "pwm_hz")); + request = fan_request(25000, 500, 1000); + request.sink_duty_set = false; + assert(!db_request_validate(&request, error, sizeof(error)) && strstr(error, "sink_duty_pct")); +#endif + uint16_t tenths = 0; + assert(db_fan_sink_duty_from_pct(0.0, &tenths) && tenths == 0); + assert(db_fan_sink_duty_from_pct(100.0, &tenths) && tenths == 1000); + assert(db_fan_sink_duty_from_pct(33.3, &tenths) && tenths == 333); + assert(db_fan_sink_duty_from_pct(0.1, &tenths) && tenths == 1); + assert(!db_fan_sink_duty_from_pct(33.35, &tenths)); + assert(!db_fan_sink_duty_from_pct(-0.1, &tenths)); + assert(!db_fan_sink_duty_from_pct(100.1, &tenths)); + assert(!db_fan_sink_duty_from_pct(NAN, &tenths)); + assert(!db_fan_sink_duty_from_pct(INFINITY, &tenths)); +} + +static void test_stimulus_plan(void) { + db_fan_stimulus_t stimulus; + // 0 % and 100 % are static gate levels, never LEDC. + assert(db_fan_stimulus_plan(25000, 0, &stimulus)); + assert(stimulus.mode == DB_FAN_STIMULUS_STATIC_RELEASE && stimulus.resolution_bits == 0 && + stimulus.duty_counts == 0 && stimulus.applied_milli_pct == 0); + assert(db_fan_stimulus_plan(DB_FAN_PWM_HZ_MIN, 1000, &stimulus)); + assert(stimulus.mode == DB_FAN_STIMULUS_STATIC_SINK && stimulus.resolution_bits == 0 && + stimulus.duty_counts == 0 && stimulus.applied_milli_pct == 100000); + assert(strcmp(db_fan_stimulus_mode_name(DB_FAN_STIMULUS_STATIC_SINK), "static_sink") == 0); + assert(db_fan_stimulus_plan(25000, 500, &stimulus)); + assert(stimulus.mode == DB_FAN_STIMULUS_PWM && stimulus.resolution_bits == 11 && stimulus.duty_counts == 1024 && + stimulus.applied_milli_pct == 50000); + assert(db_fan_stimulus_plan(25000, 333, &stimulus)); + assert(stimulus.duty_counts == 682 && stimulus.applied_milli_pct == 33301); + assert(!db_fan_stimulus_plan(DB_FAN_PWM_HZ_MIN - 1, 500, &stimulus)); + assert(!db_fan_stimulus_plan(DB_FAN_PWM_HZ_MAX + 1, 500, &stimulus)); + assert(!db_fan_stimulus_plan(25000, 1001, &stimulus)); + // Every accepted frequency keeps 0.1 % resolution and a legal LEDC divider, + // the compare value stays strictly inside 1..2^bits-1, and the programmed + // duty is within half a count of the request. + static const uint16_t duties[] = {1, 355, 999}; + for (uint32_t hz = DB_FAN_PWM_HZ_MIN; hz <= DB_FAN_PWM_HZ_MAX; ++hz) { + for (size_t i = 0; i < sizeof(duties) / sizeof(duties[0]); ++i) { + assert(db_fan_stimulus_plan(hz, duties[i], &stimulus)); + assert(stimulus.mode == DB_FAN_STIMULUS_PWM); + assert(stimulus.resolution_bits >= DB_FAN_MIN_RESOLUTION_BITS && + stimulus.resolution_bits <= DB_FAN_MAX_RESOLUTION_BITS); + const uint64_t divider_x256 = + (uint64_t)DB_FAN_LEDC_SOURCE_HZ * 256U / ((uint64_t)hz << stimulus.resolution_bits); + assert(divider_x256 >= 256U && divider_x256 <= DB_FAN_LEDC_DIVIDER_MAX_X256); + assert(stimulus.duty_counts >= 1 && stimulus.duty_counts < (1U << stimulus.resolution_bits)); + const int64_t error_milli = (int64_t)stimulus.applied_milli_pct - (int64_t)duties[i] * 100; + const int64_t half_count_milli = (100000 >> stimulus.resolution_bits) / 2 + 1; + assert(error_milli <= half_count_milli && error_milli >= -half_count_milli); + } + } +} + +static void test_tach_math(void) { + uint64_t milli = 0; + assert(db_fan_edge_hz_milli(100, 1000000, &milli) && milli == 100000); + assert(db_fan_edge_hz_milli(1, 3000000, &milli) && milli == 333); + assert(db_fan_edge_hz_milli(2, 3000000, &milli) && milli == 667); + assert(db_fan_edge_hz_milli(0, 1000000, &milli) && milli == 0); + assert(db_fan_edge_hz_milli(600, 1000000, &milli) && milli == 600000); + assert(!db_fan_edge_hz_milli(10, 0, &milli)); + assert(!db_fan_edge_hz_milli(10, -1, &milli)); + assert(!db_fan_edge_hz_milli(-1, 1000000, &milli)); + // RPM only with a configured PPR. + assert(!db_fan_rpm_milli(200, 1000000, 0, &milli)); + assert(db_fan_rpm_milli(200, 1000000, 2, &milli) && milli == 6000000); + assert(db_fan_rpm_milli(300, 1000000, 1, &milli) && milli == 18000000); + assert(!db_fan_rpm_milli(200, 1000000, DB_FAN_TACH_PPR_MAX + 1, &milli)); + assert(!db_fan_rpm_milli(200, 0, 2, &milli)); +} + +static void test_metrics_unknown_and_known_ppr(void) { + db_fan_hold_result_t result = { + .window_start_us = 1000000, .window_end_us = 3000000, .released_us = 3000100, + .edge_count = 400, .tach_valid = true, .released = true, + }; + char metrics[DB_FAN_METRICS_JSON_LEN]; + assert(db_fan_format_metrics(metrics, sizeof(metrics), &result, 0)); + assert(strcmp(metrics, "{\"tach_edges\":400,\"window_start_us\":1000000,\"window_us\":2000000," + "\"edge_hz\":200.000,\"ppr\":null,\"rpm\":null,\"released_us\":3000100}") == 0); + assert(db_fan_format_metrics(metrics, sizeof(metrics), &result, 2)); + assert(strstr(metrics, "\"ppr\":2,\"rpm\":6000.000")); + result.tach_valid = false; + result.released = false; + result.error = "tach_read_failed"; + assert(db_fan_format_metrics(metrics, sizeof(metrics), &result, 0)); + assert(strcmp(metrics, "{\"released\":false,\"fixture_error\":\"tach_read_failed\"}") == 0); + assert(!db_fan_format_metrics(metrics, 20, &result, 0)); +} + +static void test_parameters_name_the_stimulus(void) { + char parameters[DB_FAN_PARAMETERS_JSON_LEN]; + db_fan_stimulus_t stimulus; + assert(db_fan_stimulus_plan(25000, 0, &stimulus)); + assert(db_fan_format_parameters(parameters, sizeof(parameters), 25000, 0, 1000, &stimulus)); + assert(strcmp(parameters, "{\"pwm_hz\":25000,\"sink_duty_pct\":0.0,\"duration_ms\":1000," + "\"stimulus\":\"static_release\",\"applied_sink_duty_pct\":0.000}") == 0); + assert(db_fan_stimulus_plan(25000, 1000, &stimulus)); + assert(db_fan_format_parameters(parameters, sizeof(parameters), 25000, 1000, 1000, &stimulus)); + assert(strcmp(parameters, "{\"pwm_hz\":25000,\"sink_duty_pct\":100.0,\"duration_ms\":1000," + "\"stimulus\":\"static_sink\",\"applied_sink_duty_pct\":100.000}") == 0); + assert(db_fan_stimulus_plan(25000, 355, &stimulus)); + assert(db_fan_format_parameters(parameters, sizeof(parameters), 25000, 355, 1000, &stimulus)); + assert(strstr(parameters, "\"stimulus\":\"pwm\",\"applied_sink_duty_pct\":35.498,\"duty_resolution_bits\":11}")); +} + +// 0 %, an intermediate PWM value, and 100 % for one stage polarity: the +// endpoints never touch PWM, and the line ends released even when aborted. +static void test_stimulus_modes_for_polarity(int gate_sink_level) { + static const struct { + uint16_t tenths; + db_fan_stimulus_mode_t mode; + bool uses_pwm; + } cases[] = { + {0, DB_FAN_STIMULUS_STATIC_RELEASE, false}, + {355, DB_FAN_STIMULUS_PWM, true}, + {1000, DB_FAN_STIMULUS_STATIC_SINK, false}, + }; + const int release_level = gate_sink_level ? 0 : 1; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + fake_bench_t bench; + fake_bench(&bench, gate_sink_level); + assert(db_fan_release_level(&bench.fixture) == release_level); + bench.fake.abort_after_polls = 3; + db_fan_hold_result_t result; + assert(db_fan_hold(&bench.fixture, 25000, cases[i].tenths, 60000, &result) == DB_FAN_HOLD_ABORTED); + assert(result.stimulus.mode == cases[i].mode); + assert(result.actuated == (cases[i].mode != DB_FAN_STIMULUS_STATIC_RELEASE)); + assert(line_released(&bench.fake) && result.released); + assert((strstr(bench.fake.log, "PWM") != NULL) == cases[i].uses_pwm); + } +} + +// Captures the modeled line state while the hold is running. +static uint32_t observed_sink_milli_pct; +static bool observing_should_abort(void *ctx) { + observed_sink_milli_pct = line_sink_milli_pct(ctx); + return false; +} + +static void test_hold_drives_requested_sink_fraction(int gate_sink_level) { + static const uint16_t duties[] = {0, 1, 355, 999, 1000}; + for (size_t i = 0; i < sizeof(duties) / sizeof(duties[0]); ++i) { + fake_bench_t bench; + fake_bench(&bench, gate_sink_level); + bench.ops.should_abort = observing_should_abort; + observed_sink_milli_pct = UINT32_MAX; + db_fan_hold_result_t result; + assert(db_fan_hold(&bench.fixture, 25000, duties[i], 100, &result) == DB_FAN_HOLD_PASS); + assert(observed_sink_milli_pct == result.stimulus.applied_milli_pct); + assert(line_released(&bench.fake) && result.released); + } +} + +static void test_release_on_every_path(void) { + fake_bench_t bench; + db_fan_hold_result_t result; + + // Boot: the default state is released, and nothing else is touched. + fake_bench(&bench, 1); + assert(db_fan_release(&bench.fixture) && line_released(&bench.fake)); + assert(strcmp(bench.fake.log, "S0 ") == 0); + fake_bench(&bench, 0); + assert(db_fan_release(&bench.fixture) && line_released(&bench.fake)); + assert(strcmp(bench.fake.log, "S1 ") == 0); + + // Run end: release, tach readiness, actuate, clear, hold, read, release. + fake_bench(&bench, 1); + bench.fake.tach_edges_per_ms = 3; + assert(db_fan_hold(&bench.fixture, 25000, 355, 2000, &result) == DB_FAN_HOLD_PASS); + assert(line_released(&bench.fake) && result.released); + assert(strcmp(bench.fake.log, "S0 READY PWM CLEAR READ S0 ") == 0); + assert(bench.fake.pwm_hz == 25000 && bench.fake.pwm_counts == result.stimulus.duty_counts); + assert(result.tach_valid && !result.error); + assert(result.window_end_us - result.window_start_us == 2000000); + assert(result.edge_count == 6000 && result.released_us >= result.window_end_us); + + // Abort. + fake_bench(&bench, 1); + bench.fake.abort_after_polls = 5; + assert(db_fan_hold(&bench.fixture, 25000, 1000, 60000, &result) == DB_FAN_HOLD_ABORTED); + assert(line_released(&bench.fake) && result.released); + assert(result.tach_valid && !result.error); + assert(result.window_end_us - result.window_start_us == 5 * DB_FAN_ABORT_POLL_MS * 1000); + assert(strcmp(bench.fake.log, "S0 READY S1 CLEAR READ S0 ") == 0); + + // Tach unavailable: refused before any actuation. + for (int level = 0; level <= 1; ++level) { + static const uint16_t duties[] = {0, 355, 1000}; + for (size_t i = 0; i < sizeof(duties) / sizeof(duties[0]); ++i) { + fake_bench(&bench, level); + bench.fake.tach_available = false; + assert(db_fan_hold(&bench.fixture, 25000, duties[i], 1000, &result) == DB_FAN_HOLD_FAIL); + assert(strcmp(result.error, "tach_unavailable") == 0 && !result.actuated && !result.tach_valid); + assert(strcmp(bench.fake.log, level ? "S0 READY S0 " : "S1 READY S1 ") == 0); + assert(line_released(&bench.fake) && result.released); + } + } + + // Errors after readiness: PWM, static sink, tach clear, tach read. + fake_bench(&bench, 1); + bench.fake.fail_pwm = true; + assert(db_fan_hold(&bench.fixture, 25000, 500, 1000, &result) == DB_FAN_HOLD_FAIL); + assert(line_released(&bench.fake) && result.released); + assert(strcmp(result.error, "actuation_failed") == 0); + + fake_bench(&bench, 0); + bench.fake.failing_drive_call = 2; // the static-sink drive + assert(db_fan_hold(&bench.fixture, 25000, 1000, 1000, &result) == DB_FAN_HOLD_FAIL); + assert(line_released(&bench.fake) && result.released); + assert(strcmp(result.error, "actuation_failed") == 0); + + fake_bench(&bench, 1); + bench.fake.fail_tach_clear = true; + assert(db_fan_hold(&bench.fixture, 25000, 500, 1000, &result) == DB_FAN_HOLD_FAIL); + assert(line_released(&bench.fake) && result.released); + assert(strcmp(result.error, "tach_clear_failed") == 0 && !result.tach_valid); + + fake_bench(&bench, 1); + bench.fake.fail_tach_read = true; + assert(db_fan_hold(&bench.fixture, 25000, 500, 1000, &result) == DB_FAN_HOLD_FAIL); + assert(line_released(&bench.fake) && result.released); + assert(strcmp(result.error, "tach_read_failed") == 0 && !result.tach_valid); + + // Invalid parameters never reach actuation and still release. + fake_bench(&bench, 1); + assert(db_fan_hold(&bench.fixture, 5, 500, 1000, &result) == DB_FAN_HOLD_FAIL); + assert(line_released(&bench.fake) && result.released); + assert(strcmp(bench.fake.log, "S0 ") == 0 && strcmp(result.error, "invalid_parameters") == 0); + fake_bench(&bench, 1); + assert(db_fan_hold(&bench.fixture, 25000, 500, 0, &result) == DB_FAN_HOLD_FAIL); + assert(strcmp(bench.fake.log, "S0 ") == 0); + + // Nothing is actuated unless the line was first released. + fake_bench(&bench, 1); + bench.fake.failing_drive_call = 1; + assert(db_fan_hold(&bench.fixture, 25000, 1000, 1000, &result) == DB_FAN_HOLD_FAIL); + assert(strcmp(result.error, "pre_release_failed") == 0); + assert(strcmp(bench.fake.log, "S0 S0 ") == 0); // the exit-path release still ran + assert(line_released(&bench.fake) && result.released); + + // A release that cannot be verified is a failure even after a clean hold. + fake_bench(&bench, 1); + bench.fake.failing_drive_call = 3; // pre-release, static sink, final release + assert(db_fan_hold(&bench.fixture, 25000, 1000, 1000, &result) == DB_FAN_HOLD_FAIL); + assert(!result.released && strcmp(result.error, "release_failed") == 0); + assert(!line_released(&bench.fake)); + + // Missing or malformed fixture. + assert(db_fan_hold(NULL, 25000, 500, 1000, &result) == DB_FAN_HOLD_FAIL); + assert(!db_fan_release(NULL)); + fake_bench(&bench, 1); + bench.fixture.gate_sink_level = 2; + assert(db_fan_hold(&bench.fixture, 25000, 500, 1000, &result) == DB_FAN_HOLD_FAIL); + assert(strcmp(bench.fake.log, "") == 0); +} + +// The phase_end envelope must fit one event slot; main.c stores truncated JSON +// otherwise. Uses generous but physically plausible values: ~115 days uptime, +// a one-hour hold at 30 kHz edges, PPR 1, and a failed release. +static void test_phase_end_fits_event_slot(void) { + db_fan_stimulus_t stimulus; + assert(db_fan_stimulus_plan(DB_FAN_PWM_HZ_MAX, 999, &stimulus)); + char parameters[DB_FAN_PARAMETERS_JSON_LEN]; + assert(db_fan_format_parameters(parameters, sizeof(parameters), DB_FAN_PWM_HZ_MAX, 999, 3600000, &stimulus)); + db_fan_hold_result_t result = { + .window_start_us = 9999999999999LL, .window_end_us = 9999999999999LL + 3600000000LL, + .released_us = 9999999999999LL + 3600001000LL, .edge_count = 108000000, .tach_valid = true, + .released = false, .error = "release_failed", + }; + char metrics[DB_FAN_METRICS_JSON_LEN]; + assert(db_fan_format_metrics(metrics, sizeof(metrics), &result, 1)); + char envelope[1024]; + const int length = snprintf(envelope, sizeof(envelope), + "{\"schema\":1,\"event\":\"phase_end\",\"seq\":99999999,\"uptime_ms\":9999999999," + "\"target\":\"esp32s3-tinys3d\",\"firmware_version\":\"0.1.0\",\"phase\":\"FAN_PWM_HOLD\"," + "\"run_id\":\"ffffffff-ffffffff\",\"result\":\"aborted\",\"parameters\":%s,\"metrics\":%s}", + parameters, metrics); + assert(length > 0 && length < DB_EVENT_JSON_LEN); +} + +int main(void) { + test_capability_gate(); + test_parameter_bounds(); + test_stimulus_plan(); + test_tach_math(); + test_metrics_unknown_and_known_ppr(); + test_parameters_name_the_stimulus(); + for (int level = 0; level <= 1; ++level) { + test_stimulus_modes_for_polarity(level); + test_hold_drives_requested_sink_fraction(level); + } + test_release_on_every_path(); + test_phase_end_fits_event_slot(); + printf("db_fan tests passed (%s experiment profile)\n", DB_EXPERIMENT_PROFILE_NAME); + return 0; +} diff --git a/tests/native/test_db_run.c b/tests/native/test_db_run.c index 4975468..6f3c383 100644 --- a/tests/native/test_db_run.c +++ b/tests/native/test_db_run.c @@ -72,5 +72,31 @@ int main(void) { assert(run.state == DB_RUN_COMPLETE); assert(strcmp(run.result, "aborted") == 0); assert(run.started_ms == 10 && run.ended_ms == 20); + + // Status light: green only when ready and idle after a pass; off otherwise. + db_run_t status = {.state = DB_RUN_IDLE}; + assert(!db_status_ready(false, &status)); // boot / early init + assert(db_status_ready(true, &status)); // ready, no run yet + assert(!db_status_ready(true, NULL)); + for (int w = 0; w < DB_WORKLOAD_COUNT; ++w) { + const db_run_request_t each = {.workload = (db_workload_t)w, .duration_ms = 1000}; + db_run_begin(&status, &each, "boot0001-00000002", 30); + assert(!db_status_ready(true, &status)); // off for the whole run, every workload + assert(db_run_abort(&status, "boot0001-00000002")); + assert(!db_status_ready(true, &status)); // aborting is still measurement + db_run_finish(&status, "aborted", 40); + assert(!db_status_ready(true, &status)); + db_run_begin(&status, &each, "boot0001-00000003", 50); + db_run_finish(&status, "fail", 60); + assert(!db_status_ready(true, &status)); + db_run_begin(&status, &each, "boot0001-00000004", 70); + assert(!db_status_ready(true, &status)); + db_run_finish(&status, "pass", 80); + // A passed CONTROLLED_REBOOT restarts next; it never shows ready. + assert(db_status_ready(true, &status) == (each.workload != DB_CONTROLLED_REBOOT)); + assert(!db_status_ready(false, &status)); + } + db_run_finish(&status, NULL, 90); + assert(!db_status_ready(true, &status)); return 0; } diff --git a/tests/test_board_profiles.py b/tests/test_board_profiles.py new file mode 100644 index 0000000..54a4331 --- /dev/null +++ b/tests/test_board_profiles.py @@ -0,0 +1,192 @@ +"""Board profiles: what each sdkconfig.defaults[.] layering configures. + +A board profile states the module's flash, PSRAM line mode, reported identity, +and board-owned GPIOs. It never selects an experiment (test_experiment_profiles.py). +""" + +import json +import os +import re +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +# A module import, so this file does not re-collect ExperimentProfileTests. +import test_experiment_profiles as profiles + +ROOT = Path(__file__).parents[1] +KCONFIG = ROOT / "firmware/targets/esp32s3/main/Kconfig.projbuild" +FIXTURE_C = ROOT / "firmware/targets/esp32s3/main/fan_characterization.c" + +EXPECTED = { + # board: (CONFIG_DB_TARGET_NAME, flash size, PSRAM mode, RF-switch GPIO, + # status RGB data GPIO, status RGB power GPIO) + "n8r8": ("esp32s3-n8r8", "8MB", "OCT", -1, 48, -1), + "n16r8": ("esp32s3-n16r8", "16MB", "OCT", -1, 48, -1), + "tinys3d": ("esp32s3-tinys3d", "8MB", "QUAD", 38, 18, 17), +} +GPIO_SETTINGS = ("DB_RF_SWITCH_GPIO", "DB_STATUS_RGB_GPIO", "DB_STATUS_RGB_POWER_GPIO", + "DB_STATUS_RGB_POWER_ACTIVE_LEVEL") + + +def _kconfig_int_default(name): + return int(re.search(rf"config {name}\n(?:.*\n)*?\s+default (-?\d+)", KCONFIG.read_text()).group(1)) + + +def resolved(board): + """Settings after layering the board's defaults files in order, as ESP-IDF does.""" + settings = {} + for layer in profiles.board_profiles()[board]: + for line in (ROOT / layer).read_text().splitlines(): + unset = re.fullmatch(r"# (CONFIG_\w+) is not set", line) + if unset: + settings.pop(unset.group(1), None) + elif line.startswith("CONFIG_"): + key, value = line.split("=", 1) + settings[key] = value + for name in GPIO_SETTINGS: + settings.setdefault(f"CONFIG_{name}", str(_kconfig_int_default(name))) + return settings + + +def _selected(settings, prefix, options): + chosen = [o for o in options if settings.get(f"{prefix}{o}") == "y"] + return chosen[0] if len(chosen) == 1 else chosen + + +class BoardProfileTests(unittest.TestCase): + def test_every_board_is_described(self): + self.assertEqual(sorted(profiles.board_profiles()), sorted(EXPECTED)) + + def test_board_configuration(self): + for board, (target, flash, psram, rf_switch, rgb, rgb_power) in EXPECTED.items(): + with self.subTest(board=board): + settings = resolved(board) + self.assertEqual(settings.get("CONFIG_DB_TARGET_NAME", '"esp32s3-n8r8"'), f'"{target}"') + self.assertEqual(_selected(settings, "CONFIG_ESPTOOLPY_FLASHSIZE_", + ("2MB", "4MB", "8MB", "16MB", "32MB")), flash) + self.assertEqual(settings.get("CONFIG_SPIRAM"), "y") + self.assertEqual(_selected(settings, "CONFIG_SPIRAM_MODE_", ("QUAD", "OCT")), psram) + self.assertEqual(int(settings["CONFIG_DB_RF_SWITCH_GPIO"]), rf_switch) + self.assertEqual(int(settings["CONFIG_DB_STATUS_RGB_GPIO"]), rgb) + self.assertEqual(int(settings["CONFIG_DB_STATUS_RGB_POWER_GPIO"]), rgb_power) + # TinyS3[D] schematic Rev D-P1: GPIO17 high powers the LED. + self.assertEqual(int(settings["CONFIG_DB_STATUS_RGB_POWER_ACTIVE_LEVEL"]), 1) + + def test_n16r8_is_octal_psram_at_80_mhz(self): + settings = resolved("n16r8") + self.assertEqual(settings.get("CONFIG_SPIRAM_SPEED_80M"), "y") + self.assertEqual(settings.get("CONFIG_PARTITION_TABLE_CUSTOM_FILENAME"), '"partitions.csv"') + + def test_n8r8_remains_the_default_board(self): + default = re.search(r'config DB_TARGET_NAME\n(?:.*\n)*?\s+default "([^"]+)"', KCONFIG.read_text()).group(1) + self.assertEqual(default, "esp32s3-n8r8") + self.assertEqual(profiles.board_profiles()["n8r8"], ["sdkconfig.defaults"]) + + def test_every_board_identity_is_a_protocol_target(self): + schema = json.loads((ROOT / "protocol/event.schema.json").read_text()) + targets = schema["properties"]["target"]["enum"] + openapi = re.search(r"board_profile: \{type: string, enum: \[([^\]]*)\]\}", + (ROOT / "protocol/openapi.yaml").read_text()).group(1) + expected = sorted(target for target, *_ in EXPECTED.values()) + self.assertEqual(sorted(targets), expected) + self.assertEqual(sorted(t.strip() for t in openapi.split(",")), expected) + self.assertEqual(len(targets), len(set(targets))) + + def test_partition_layout_stays_below_8_mb_on_every_board(self): + # The N16R8 has 16 MB of physical flash; the layout deliberately uses + # only the lower 8 MB so one partitions.csv serves every board. + end = 0 + for line in (ROOT / "partitions.csv").read_text().splitlines(): + if not line.strip() or line.startswith("#"): + continue + fields = [f.strip() for f in line.split(",")] + end = max(end, int(fields[3], 0) + int(fields[4], 0)) + self.assertGreater(end, 0) + self.assertLessEqual(end, 8 * 1024 * 1024) + + +@unittest.skipUnless(shutil.which("cc") or os.environ.get("DRAGONBENCH_REQUIRE_CC"), + "a C compiler is required to preprocess the fixture pin guards") +class FixturePinGuardTests(unittest.TestCase): + """Runs the real fan-characterization pin guards through the C preprocessor + with each board's resolved configuration.""" + + @classmethod + def setUpClass(cls): + source = FIXTURE_C.read_text() + start = source.index("#if CONFIG_DB_FAN_PWM_GATE_GPIO < 0\n") + guards = source[start:source.index("// RPM is derived", start)] + cls.tmp = tempfile.TemporaryDirectory() + cls.guards = Path(cls.tmp.name) / "guards.c" + # Close the "all fixture settings set" block the excerpt opens. + cls.guards.write_text('#include "db_fan.h"\n' + guards + "#endif\n") + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + + def _errors(self, board, gate, tach, sink=1): + settings = resolved(board) + defines = [f"-DCONFIG_DB_FAN_PWM_GATE_GPIO={gate}", f"-DCONFIG_DB_FAN_TACH_GPIO={tach}", + f"-DCONFIG_DB_FAN_GATE_SINK_LEVEL={sink}"] + defines += [f"-DCONFIG_{name}={settings[f'CONFIG_{name}']}" for name in GPIO_SETTINGS] + if settings.get("CONFIG_SPIRAM_MODE_OCT") == "y": + defines.append("-DCONFIG_SPIRAM_MODE_OCT=1") + result = subprocess.run(["cc", "-E", "-o", os.devnull, *defines, + f"-I{ROOT / 'firmware/common/include'}", str(self.guards)], + capture_output=True, text=True) + errors = re.findall(r'#error "fan-characterization: ([^"]+)"', result.stderr) + self.assertEqual(result.returncode != 0, bool(errors), result.stderr) + return errors + + def assertAccepted(self, board, gate, tach, sink=1): + self.assertEqual(self._errors(board, gate, tach, sink), [], f"{board} gate={gate} tach={tach}") + + def assertRejected(self, board, gate, tach, reason): + errors = self._errors(board, gate, tach) + self.assertTrue(any(reason in e for e in errors), f"{board} gate={gate} tach={tach}: {errors}") + + def test_hosted_ci_fixtures_pass_their_board_guards(self): + self.assertAccepted("n16r8", 6, 7, sink=1) + self.assertAccepted("n8r8", 6, 7, sink=0) + self.assertAccepted("tinys3d", 4, 5, sink=1) + + def test_status_rgb_data_pin_is_rejected_as_gate_and_tach(self): + for board in ("n8r8", "n16r8"): + self.assertRejected(board, 48, 7, "CONFIG_DB_STATUS_RGB_GPIO") + self.assertRejected(board, 6, 48, "CONFIG_DB_STATUS_RGB_GPIO") + self.assertRejected("tinys3d", 18, 5, "CONFIG_DB_STATUS_RGB_GPIO") + self.assertRejected("tinys3d", 4, 18, "CONFIG_DB_STATUS_RGB_GPIO") + + def test_status_rgb_power_pin_is_rejected_as_gate_and_tach(self): + self.assertRejected("tinys3d", 17, 5, "CONFIG_DB_STATUS_RGB_POWER_GPIO") + self.assertRejected("tinys3d", 4, 17, "CONFIG_DB_STATUS_RGB_POWER_GPIO") + + def test_status_rgb_pins_are_reserved_only_on_the_board_that_wires_them(self): + self.assertAccepted("tinys3d", 48, 5) + for board in ("n8r8", "n16r8"): + self.assertAccepted(board, 17, 18) + self.assertAccepted(board, 18, 17) + + def test_tinys3d_still_rejects_its_rf_switch(self): + self.assertRejected("tinys3d", 38, 5, "CONFIG_DB_RF_SWITCH_GPIO") + self.assertRejected("tinys3d", 4, 38, "CONFIG_DB_RF_SWITCH_GPIO") + + def test_octal_psram_pins_are_rejected_on_octal_boards_only(self): + for board in ("n8r8", "n16r8"): + for gpio in range(33, 38): + self.assertRejected(board, gpio, 7, "flash/PSRAM") + self.assertAccepted("tinys3d", 33, 5) + + def test_shared_reservations_hold_on_every_board(self): + for board in EXPECTED: + for gpio in (0, 3, 45, 46, 19, 20, 26, 43, 44, 49): + self.assertRejected(board, gpio, 7 if board != "tinys3d" else 5, "strapping") + self.assertRejected(board, 6, 6, "different GPIOs") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_experiment_profiles.py b/tests/test_experiment_profiles.py new file mode 100644 index 0000000..a5afd6c --- /dev/null +++ b/tests/test_experiment_profiles.py @@ -0,0 +1,128 @@ +"""DragonBench images are one board profile plus one experiment profile. + +Experiment profiles are a mutually exclusive Kconfig choice, never independently +combinable feature flags. Each non-baseline profile has exactly one overlay, +one name, one CI entry, and code compiled only under its DB_EXPERIMENT_* macro. +""" + +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).parents[1] +KCONFIG = ROOT / "firmware/targets/esp32s3/main/Kconfig.projbuild" +EXPERIMENT_H = ROOT / "firmware/common/include/db_experiment.h" +EXPECTED = {"DB_EXPERIMENT_BASELINE": "baseline", + "DB_EXPERIMENT_FAN_CHARACTERIZATION": "fan-characterization"} +EXPERIMENT_OVERLAYS = sorted(f"sdkconfig.defaults.{n}" for n in EXPECTED.values() if n != "baseline") + + +def board_profiles(): + """Board name -> SDKCONFIG_DEFAULTS layering, as ci/build-esp32s3.sh builds it. + + The build script's board case is the one place a board profile is defined; + a board overlay is any file it layers beyond the shared sdkconfig.defaults. + """ + script = (ROOT / "ci/build-esp32s3.sh").read_text() + cases = script[script.index('case "$board" in\n'):] + cases = cases[:cases.index("\nesac\n")] + return {board: defaults.split(";") + for board, defaults in re.findall(r'^ ([\w-]+)\)\n defaults="([^"$]+)"$', cases, re.M)} + + +def board_overlays(): + return sorted({f for layers in board_profiles().values() for f in layers if f != "sdkconfig.defaults"}) + + +def _choice_block(): + kconfig = KCONFIG.read_text() + start = kconfig.index("choice DB_EXPERIMENT_PROFILE\n") + return kconfig[start:kconfig.index("endchoice", start)] + + +class ExperimentProfileTests(unittest.TestCase): + def test_profiles_are_one_mutually_exclusive_choice(self): + block = _choice_block() + self.assertIn("default DB_EXPERIMENT_BASELINE", block) + self.assertEqual(sorted(re.findall(r"^config (DB_EXPERIMENT_\w+)$", block, re.M)), sorted(EXPECTED)) + # No experiment switch lives outside the choice. + kconfig = KCONFIG.read_text() + outside = kconfig.replace(block, "") + self.assertEqual(re.findall(r"^config (DB_EXPERIMENT_\w+)$", outside, re.M), []) + self.assertNotIn("config DB_FAN_FIXTURE", kconfig) + + def test_every_profile_has_one_name(self): + header = EXPERIMENT_H.read_text() + for symbol, name in EXPECTED.items(): + self.assertIn(f'#define DB_EXPERIMENT_PROFILE_NAME "{name}"', header, symbol) + self.assertEqual(header.count("#define DB_EXPERIMENT_PROFILE_NAME"), len(EXPECTED)) + + def test_board_profiles_are_explicit(self): + boards = board_profiles() + self.assertEqual(sorted(boards), ["n16r8", "n8r8", "tinys3d"]) + for board, layers in boards.items(): + # Every board layers on the shared defaults and names no experiment. + self.assertEqual(layers[0], "sdkconfig.defaults", board) + self.assertFalse(set(layers) & set(EXPERIMENT_OVERLAYS), board) + + def test_every_defaults_overlay_is_either_a_board_or_an_experiment(self): + overlays = sorted(p.name for p in ROOT.glob("sdkconfig.defaults.*")) + self.assertEqual(overlays, sorted(board_overlays() + EXPERIMENT_OVERLAYS)) + self.assertIn("sdkconfig.defaults.n16r8", board_overlays()) + self.assertNotIn("sdkconfig.defaults.n16r8", EXPERIMENT_OVERLAYS) + + def test_baseline_is_explicit_and_boards_do_not_choose_experiments(self): + self.assertIn("CONFIG_DB_EXPERIMENT_BASELINE=y", (ROOT / "sdkconfig.defaults").read_text()) + for overlay in board_overlays(): + self.assertNotIn("DB_EXPERIMENT", (ROOT / overlay).read_text(), overlay) + + def test_each_experiment_has_exactly_one_overlay_selecting_only_itself(self): + for overlay in EXPERIMENT_OVERLAYS: + self.assertTrue((ROOT / overlay).is_file(), overlay) + for symbol, name in EXPECTED.items(): + if name == "baseline": + continue + settings = [l for l in (ROOT / f"sdkconfig.defaults.{name}").read_text().splitlines() + if l.startswith("CONFIG_DB_EXPERIMENT_")] + self.assertEqual(settings, [f"CONFIG_{symbol}=y"], name) + # An experiment overlay is not a board profile. + self.assertNotIn("CONFIG_DB_TARGET_NAME", (ROOT / f"sdkconfig.defaults.{name}").read_text(), name) + + def test_ci_knows_every_experiment(self): + script = (ROOT / "ci/build-esp32s3.sh").read_text() + listed = re.search(r"^all_experiments=\(([^)]*)\)", script, re.M).group(1).split() + self.assertEqual(sorted(listed), sorted(n for n in EXPECTED.values() if n != "baseline")) + + def test_every_experiment_has_a_configured_hosted_build(self): + # Refusal and static checks are not enough: hosted CI must compile each + # non-baseline experiment's real backend, with runner-local wiring. + workflow = (ROOT / ".github/workflows/ci.yml").read_text() + jobs = re.split(r"^ (?=[\w-]+:\n)", workflow.split("\njobs:\n", 1)[1], flags=re.M) + for name in (n for n in EXPECTED.values() if n != "baseline"): + configured = [job for job in jobs + if re.search(rf"command: bash ci/build-esp32s3\.sh .+ {re.escape(name)}[ \t]*$", job, re.M)] + self.assertTrue(configured, f"{name} has no configured ESP-IDF CI build") + for job in configured: + self.assertIn(f"cat > sdkconfig.{name}.local", job, name) + self.assertIn("CI COMPILE FIXTURE", job, name) + # Every board profile compiles the experiment's real backend. + self.assertEqual(sorted(re.findall(r"^ - board: ([\w-]+)$", job, re.M)), + sorted(board_profiles()), name) + + def test_every_board_has_a_hosted_baseline_build(self): + workflow = (ROOT / ".github/workflows/ci.yml").read_text() + job = workflow[workflow.index("\n esp32s3-build:\n"):] + job = job[:job.index("\n fan-characterization-configured:\n")] + self.assertIn("command: bash ci/build-esp32s3.sh ${{ matrix.profile }} baseline", job) + profiles = re.search(r"^ profile: \[([^\]]*)\]$", job, re.M).group(1) + self.assertEqual(sorted(p.strip() for p in profiles.split(",")), sorted(board_profiles())) + + def test_code_selects_experiments_only_through_db_experiment_h(self): + for path in (ROOT / "firmware").rglob("*.[ch]"): + if path == EXPERIMENT_H: + continue + self.assertNotIn("CONFIG_DB_EXPERIMENT_", path.read_text(), path) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_fan_characterization_contract.py b/tests/test_fan_characterization_contract.py new file mode 100644 index 0000000..54e20c5 --- /dev/null +++ b/tests/test_fan_characterization_contract.py @@ -0,0 +1,197 @@ +"""Static contract for the fan-characterization experiment profile. + +The profile is characterization equipment: an external open-drain PWM stimulus +and tach capture, compiled only when this experiment profile is selected. These +checks keep it out of every other profile, keep it from growing product fan +control, and keep guessed wiring or fan constants out of it. The profile +mechanism itself is covered by test_experiment_profiles.py. +""" + +import re +import unittest +from pathlib import Path + +from cli.dragonbench import main as cli + +ROOT = Path(__file__).parents[1] +MAIN_C = ROOT / "firmware/targets/esp32s3/main/main.c" +FIXTURE_C = ROOT / "firmware/targets/esp32s3/main/fan_characterization.c" +DB_FAN_H = ROOT / "firmware/common/include/db_fan.h" +KCONFIG = ROOT / "firmware/targets/esp32s3/main/Kconfig.projbuild" + + +def _read(path): + return (path if isinstance(path, Path) else ROOT / path).read_text() + + +def _kconfig_default(name): + match = re.search(rf"config {name}\n(?:.*\n)*?\s+default (\S+)", _read(KCONFIG)) + return match.group(1) + + +def _function_body(source, signature): + start = source.index(signature) + return source[start:source.index("\n}\n", start)] + + +class ProfileIsolationTests(unittest.TestCase): + def test_fixture_wiring_has_no_defaults(self): + self.assertEqual(_kconfig_default("DB_FAN_PWM_GATE_GPIO"), "-1") + self.assertEqual(_kconfig_default("DB_FAN_GATE_SINK_LEVEL"), "-1") + self.assertEqual(_kconfig_default("DB_FAN_TACH_GPIO"), "-1") + self.assertEqual(_kconfig_default("DB_FAN_TACH_PPR"), "0") + self.assertEqual(_kconfig_default("DB_FAN_TACH_INTERNAL_PULLUP"), "n") + self.assertEqual(_kconfig_default("DB_FAN_TACH_PPR_EVIDENCE"), '""') + + def test_fixture_wiring_exists_only_inside_the_profile(self): + kconfig = _read(KCONFIG) + block = kconfig[kconfig.index("if DB_EXPERIMENT_FAN_CHARACTERIZATION\n"):] + block = block[:block.index("\nendif\n")] + self.assertEqual(kconfig.count("config DB_FAN_"), block.count("config DB_FAN_")) + for board in ("sdkconfig.defaults", "sdkconfig.defaults.n16r8", "sdkconfig.defaults.tinys3d"): + self.assertNotIn("DB_FAN", _read(board), board) + + def test_overlay_only_selects_the_profile_and_assigns_no_pins(self): + overlay = _read("sdkconfig.defaults.fan-characterization") + settings = [line for line in overlay.splitlines() if line and not line.startswith("#")] + self.assertEqual(settings, ["CONFIG_DB_EXPERIMENT_FAN_CHARACTERIZATION=y"]) + self.assertIn("# CONFIG_DB_EXPERIMENT_BASELINE is not set", overlay) + self.assertIn("sdkconfig.fan-characterization.local", _read(".gitignore")) + + def test_unconfigured_profile_refuses_to_build(self): + source = _read(FIXTURE_C) + for setting in ("CONFIG_DB_FAN_PWM_GATE_GPIO", "CONFIG_DB_FAN_GATE_SINK_LEVEL", "CONFIG_DB_FAN_TACH_GPIO"): + self.assertRegex(source, rf"#if {setting} < 0\n#error \"fan-characterization: set {setting}") + self.assertIn("DB_FAN_PIN_RESERVED(CONFIG_DB_FAN_PWM_GATE_GPIO", source) + self.assertIn("DB_FAN_PIN_RESERVED(CONFIG_DB_FAN_TACH_GPIO", source) + self.assertIn("CONFIG_DB_RF_SWITCH_GPIO", source) + self.assertIn("CONFIG_DB_STATUS_RGB_GPIO", source) + self.assertIn("CONFIG_DB_STATUS_RGB_POWER_GPIO", source) + self.assertIn("CONFIG_DB_FAN_TACH_PPR_EVIDENCE", source) + + def test_profile_code_is_compiled_out_of_other_profiles(self): + fixture = _read(FIXTURE_C) + self.assertLess(fixture.index("#if DB_EXPERIMENT_FAN_CHARACTERIZATION"), fixture.index("driver/ledc.h")) + main = _read(MAIN_C) + for call in ("fan_characterization_boot()", "fan_characterization_hold(", "fan_characterization_describe("): + idx = main.index(call) + # Inside an open "#if DB_EXPERIMENT_FAN_CHARACTERIZATION" block. + self.assertGreater(main.rfind("#if DB_EXPERIMENT_FAN_CHARACTERIZATION", 0, idx), + main.rfind("#endif", 0, idx), call) + self.assertIn("return DB_EXPERIMENT_FAN_CHARACTERIZATION;", _read("firmware/common/db_run.c")) + + def test_ci_proves_absence_and_refusal(self): + script = _read("ci/build-esp32s3.sh") + self.assertIn('[fan-characterization]="ledc_|pcnt_|fan_characterization_"', script) + self.assertIn("links $other code", script) + workflow = _read(".github/workflows/ci.yml") + self.assertIn("ci/build-esp32s3.sh tinys3d fan-characterization --expect-refusal", workflow) + self.assertIn("-DCONFIG_DB_EXPERIMENT_FAN_CHARACTERIZATION=1", workflow) + + +class ProductBoundaryTests(unittest.TestCase): + def test_product_actuator_capabilities_are_false_in_every_build(self): + main = _read(MAIN_C) + identity = _function_body(main, "static cJSON *identity_json") + heater = identity.index('"heater_capability", false') + fan = identity.index('"fan_control_capability", false') + guard = identity.index("#if DB_EXPERIMENT_FAN_CHARACTERIZATION") + self.assertLess(heater, guard) + self.assertLess(fan, guard) + self.assertIn('"fan_pwm_fixture", DB_EXPERIMENT_FAN_CHARACTERIZATION', identity) + + def test_no_heater_path_anywhere_in_firmware(self): + # "heater" may appear only where the absent capability is reported. + allowed = ("capability", "no heater") + for path in (ROOT / "firmware").rglob("*"): + if path.suffix not in (".c", ".h", ".projbuild", ".txt"): + continue + for number, line in enumerate(path.read_text(errors="ignore").splitlines(), 1): + lowered = line.lower() + if "heater" in lowered: + self.assertTrue(any(token in lowered for token in allowed), f"{path}:{number}: {line}") + + def test_tach_never_feeds_the_stimulus(self): + source = _read(FIXTURE_C) + for helper in ("ledc_timer_config", "ledc_channel_config", "ledc_set_duty", "ledc_update_duty", + "ledc_set_freq", "ledc_set_fade"): + for match in re.finditer(rf"\b{helper}\(", source): + enclosing = source.rindex("\nstatic ", 0, match.start()) + self.assertTrue(source.startswith("\nstatic bool start_pwm", enclosing), helper) + common = _read("firmware/common/db_fan.c") + hold = _function_body(common, "db_fan_hold_outcome_t db_fan_hold(") + self.assertEqual(hold.count("actuate("), 1) + self.assertLess(hold.index("actuate("), hold.index("ops->tach_read(")) + self.assertNotIn("tach", _function_body(common, "static bool actuate(")) + + def test_release_is_first_in_boot_and_on_every_hold_exit(self): + main = _read(MAIN_C) + app_main = main[main.index("void app_main(void) {"):] + first_statement = app_main.split("\n")[2].strip() + self.assertEqual(first_statement, "fan_characterization_boot(); // release the stimulus line before anything else") + common = _read("firmware/common/db_fan.c") + hold = _function_body(common, "db_fan_hold_outcome_t db_fan_hold(") + tail = hold[hold.index("\nrelease:"):] + self.assertIn("db_fan_release(fixture)", tail) + self.assertNotIn("return", hold[hold.index("actuate("):hold.index("\nrelease:")]) + self.assertIn("return fixture->gate_sink_level ? 0 : 1;", common) + fixture = _read(FIXTURE_C) + self.assertIn("esp_register_shutdown_handler(shutdown_release)", fixture) + drive = _function_body(fixture, "static bool drive_static") + self.assertLess(drive.index("gpio_set_level(GATE_GPIO, gate_level)"), drive.index("gpio_config(")) + self.assertIn("GPIO_PULLUP_DISABLE", drive) + self.assertIn("GPIO_PULLDOWN_DISABLE", drive) + + def test_tach_readiness_is_established_before_actuation(self): + hold = _function_body(_read("firmware/common/db_fan.c"), "db_fan_hold_outcome_t db_fan_hold(") + ready = hold.index("ops->tach_ready(") + self.assertLess(hold.index("db_fan_release(fixture)"), ready) + self.assertLess(ready, hold.index("actuate(")) + # The counter is cleared after actuation, immediately before the window opens. + clear = hold.index("ops->tach_clear(") + self.assertLess(hold.index("actuate("), clear) + self.assertLess(clear, hold.index("result->window_start_us = ops->now_us(")) + + def test_static_endpoints_never_use_ledc(self): + plan = _function_body(_read("firmware/common/db_fan.c"), "bool db_fan_stimulus_plan(") + self.assertLess(plan.index("DB_FAN_STIMULUS_STATIC_RELEASE"), plan.index("DB_FAN_STIMULUS_PWM")) + self.assertLess(plan.index("DB_FAN_STIMULUS_STATIC_SINK"), plan.index("DB_FAN_STIMULUS_PWM")) + self.assertIn("counts >= full_scale", plan) + start_pwm = _function_body(_read(FIXTURE_C), "static bool start_pwm(") + self.assertIn("duty_counts >= (1UL << resolution_bits)", start_pwm) + + def test_no_jumpjet_policy_constants(self): + sources = list((ROOT / "firmware").rglob("*.[ch]")) + [KCONFIG] + text = "\n".join(p.read_text(errors="ignore") for p in sources).upper() + policy = re.compile(r"\b\w*(MIN_DUTY|MINIMUM_DUTY|STALL_|_STALL|PROOF|RPM_MIN|MIN_RPM|RPM_THRESHOLD|" + r"FAN_TIMEOUT|FAN_PWM_HZ_DEFAULT|DEFAULT_PPR)\w*\b") + self.assertEqual(policy.findall(text), []) + + +class ContractSyncTests(unittest.TestCase): + def test_cli_limits_mirror_firmware(self): + header = _read(DB_FAN_H) + self.assertIn(f"#define DB_FAN_PWM_HZ_MIN {cli.FAN_PWM_HZ_MIN}U", header) + self.assertIn(f"#define DB_FAN_PWM_HZ_MAX {cli.FAN_PWM_HZ_MAX}U", header) + api = _read("protocol/openapi.yaml") + self.assertIn(f"pwm_hz: {{type: integer, minimum: {cli.FAN_PWM_HZ_MIN}, maximum: {cli.FAN_PWM_HZ_MAX}", api) + self.assertIn("sink_duty_pct: {type: number, minimum: 0, maximum: 100, multipleOf: 0.1", api) + self.assertIn("FAN_PWM_HOLD", api) + self.assertIn("bench_stimulus", api) + + def test_landing_pages_show_the_stimulus_separately_from_product_control(self): + from tests.test_contract import _firmware_page + for page in (_read("web/index.html"), _firmware_page("landing")): + self.assertIn('data-role="capability-fan-stimulus"', page) + self.assertIn("bench_stimulus", page) + self.assertNotIn("cannot drive a heater or fan", page) + self.assertIn("no heater or product fan-control path", page) + + def test_unknown_ppr_stays_explicit(self): + common = _read("firmware/common/db_fan.c") + self.assertIn('",\\"ppr\\":%s,\\"rpm\\":null", ppr ? "\\"invalid\\"" : "null"', common) + self.assertIn('cJSON_AddNullToObject(o, "tach_ppr")', _read(FIXTURE_C)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_fan_sweep.py b/tests/test_fan_sweep.py new file mode 100644 index 0000000..d373024 --- /dev/null +++ b/tests/test_fan_sweep.py @@ -0,0 +1,203 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from cli.dragonbench.main import ( + ClientError, FAN_PWM_HZ_MAX, FAN_PWM_HZ_MIN, duty_tenths, execute, fan_sweep, parser, sweep_steps, +) + + +class FakeFanDevice: + """Minimal stand-in for a fan-characterization device's HTTP API.""" + + def __init__(self, fixture=True, results=None, polls_until_complete=1, refuse_at=None): + self.fixture = fixture + self.results = results or {} + self.polls_until_complete = polls_until_complete + self.refuse_at = refuse_at + self.calls = [] + self.runs = {} + self.events = [] + self.seq = 0 + + def _emit(self, event, run_id, **fields): + self.seq += 1 + self.events.append({"schema": 1, "event": event, "seq": self.seq, "uptime_ms": 1000 + self.seq, + "run_id": run_id, "phase": "FAN_PWM_HOLD", **fields}) + + def request(self, method, path, body=None): + self.calls.append((method, path, body)) + if path == "/api/v1/device": + return {"product": "DragonBench", "fan_control_capability": False, + "experiment_profile": "fan-characterization" if self.fixture else "baseline", + "bench_stimulus": {"fan_pwm_fixture": self.fixture}} + if method == "POST" and path == "/api/v1/runs": + index = len(self.runs) + if index == self.refuse_at: + raise ClientError("HTTP 400: pwm_hz must be 10..50000") + run_id = f"run-{index}" + self.runs[run_id] = {"polls": 0, "body": body, "result": self.results.get(index, "pass")} + self._emit("phase_start", run_id, parameters={"sink_duty_pct": body.get("sink_duty_pct")}) + return {"run_id": run_id, "state": "running"} + if method == "GET" and path.startswith("/api/v1/runs/"): + run_id = path.rsplit("/", 1)[1] + run = self.runs[run_id] + run["polls"] += 1 + if run["polls"] < self.polls_until_complete: + return {"run_id": run_id, "state": "running", "result": "running"} + if "done" not in run: + run["done"] = True + metrics = {"tach_edges": 100, "window_us": run["body"]["duration_ms"] * 1000, + "edge_hz": 100.0, "ppr": None, "rpm": None, "released_us": 5} + self._emit("phase_end", run_id, result=run["result"], metrics=metrics, + parameters={"sink_duty_pct": run["body"]["sink_duty_pct"]}) + self._emit("run_complete", run_id, result=run["result"]) + return {"run_id": run_id, "state": "complete", "result": run["result"]} + if method == "POST" and path.endswith("/abort"): + return {"state": "aborting"} + if path == "/api/v1/events": + return list(self.events) + raise AssertionError(f"unexpected request {method} {path}") + + +def sweep_args(output, *extra): + return parser().parse_args(["fan-sweep", "--pwm-hz", "25000", "--low-pct", "20", "--high-pct", "40", + "--step-pct", "10", "--hold-ms", "2000", "--output", str(output), *extra]) + + +class SweepOrderTests(unittest.TestCase): + def test_up_and_down(self): + self.assertEqual(sweep_steps(200, 400, 100, "up"), [("up", 200), ("up", 300), ("up", 400)]) + self.assertEqual(sweep_steps(200, 400, 100, "down"), [("down", 400), ("down", 300), ("down", 200)]) + + def test_turnaround_is_held_once(self): + self.assertEqual(sweep_steps(0, 200, 100, "up-down"), + [("up", 0), ("up", 100), ("up", 200), ("down", 100), ("down", 0)]) + self.assertEqual(sweep_steps(0, 200, 100, "down-up"), + [("down", 200), ("down", 100), ("down", 0), ("up", 100), ("up", 200)]) + + def test_far_endpoint_always_included(self): + self.assertEqual([d for _, d in sweep_steps(0, 250, 100, "up")], [0, 100, 200, 250]) + self.assertEqual([d for _, d in sweep_steps(0, 250, 100, "down")], [250, 200, 100, 0]) + + def test_single_point(self): + self.assertEqual(sweep_steps(500, 500, 10, "up-down"), [("up", 500)]) + + def test_invalid_ranges(self): + with self.assertRaises(ClientError): + sweep_steps(400, 200, 100, "up") + with self.assertRaises(ClientError): + sweep_steps(0, 200, 0, "up") + with self.assertRaises(ClientError): + sweep_steps(0, 200, 100, "sideways") + + def test_duty_resolution_matches_firmware(self): + self.assertEqual(duty_tenths(33.3, "x"), 333) + self.assertEqual(duty_tenths(100, "x"), 1000) + for bad in (-0.1, 100.1, 33.35, float("nan")): + with self.assertRaises(ClientError): + duty_tenths(bad, "x") + + +class FanSweepTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.output = Path(self.tmp.name) / "sweep.json" + + def tearDown(self): + self.tmp.cleanup() + + def run_sweep(self, device, *extra): + return fan_sweep(sweep_args(self.output, *extra), device, sleep=lambda _: None) + + def test_up_down_sweep_posts_holds_in_order_and_writes_results(self): + device = FakeFanDevice(polls_until_complete=2) + summary = self.run_sweep(device, "--order", "up-down") + posts = [body for method, path, body in device.calls if (method, path) == ("POST", "/api/v1/runs")] + self.assertEqual([p["sink_duty_pct"] for p in posts], [20.0, 30.0, 40.0, 30.0, 20.0]) + for body in posts: + self.assertEqual(body["workload"], "FAN_PWM_HOLD") + self.assertEqual(body["pwm_hz"], 25000) + self.assertEqual(body["duration_ms"], 2000) + report = json.loads(self.output.read_text()) + self.assertEqual(summary["steps"], 5) + self.assertEqual(report["status"], "complete") + self.assertEqual(report["schema"], "dragonbench.fan_sweep/1") + self.assertIsNone(report["external_measurements"]) + self.assertEqual([s["direction"] for s in report["steps"]], ["up", "up", "up", "down", "down"]) + self.assertEqual(report["sweep"]["planned_sink_duty_pct"], [20.0, 30.0, 40.0, 30.0, 20.0]) + first = report["steps"][0] + self.assertEqual(first["run_id"], "run-0") + self.assertLess(first["phase_start_seq"], first["phase_end_seq"]) + self.assertIsNone(first["metrics"]["rpm"]) + self.assertIsNone(first["metrics"]["ppr"]) + + def test_refuses_normal_build_before_any_run(self): + device = FakeFanDevice(fixture=False) + with self.assertRaises(ClientError): + self.run_sweep(device, "--order", "up") + self.assertFalse([c for c in device.calls if c[0] == "POST"]) + + def test_failed_step_stops_sweep_and_keeps_partial_results(self): + device = FakeFanDevice(results={1: "fail"}) + with self.assertRaises(ClientError): + self.run_sweep(device, "--order", "up") + report = json.loads(self.output.read_text()) + self.assertEqual(report["status"], "failed") + self.assertEqual([s["result"] for s in report["steps"]], ["pass", "fail"]) + self.assertEqual(len([c for c in device.calls if c[:2] == ("POST", "/api/v1/runs")]), 2) + + def test_device_refusal_is_reported(self): + device = FakeFanDevice(refuse_at=0) + with self.assertRaises(ClientError): + self.run_sweep(device, "--order", "up") + self.assertEqual(json.loads(self.output.read_text())["status"], "failed") + + def test_timeout_aborts_active_run(self): + device = FakeFanDevice(polls_until_complete=10**9) + ticks = iter(range(0, 10**6, 5)) + args = sweep_args(self.output, "--order", "up") + with self.assertRaises(ClientError): + fan_sweep(args, device, sleep=lambda _: None, clock=lambda: next(ticks)) + self.assertIn(("POST", "/api/v1/runs/run-0/abort", {}), device.calls) + + def test_interrupt_aborts_active_run_and_records_partial(self): + device = FakeFanDevice(polls_until_complete=3) + + def interrupt(_): + raise KeyboardInterrupt + + with self.assertRaises(ClientError): + fan_sweep(sweep_args(self.output, "--order", "up"), device, sleep=interrupt) + self.assertIn(("POST", "/api/v1/runs/run-0/abort", {}), device.calls) + self.assertEqual(json.loads(self.output.read_text())["status"], "interrupted") + + def test_parameter_validation_before_network(self): + for extra in (["--pwm-hz", str(FAN_PWM_HZ_MIN - 1)], ["--pwm-hz", str(FAN_PWM_HZ_MAX + 1)], + ["--hold-ms", "0"], ["--hold-ms", "3600001"], ["--gap-ms", "-1"], + ["--step-pct", "0.05"], ["--high-pct", "100.5"]): + device = FakeFanDevice() + args = sweep_args(self.output, "--order", "up", *extra) + with self.assertRaises(ClientError, msg=extra): + fan_sweep(args, device, sleep=lambda _: None) + self.assertEqual(device.calls, [], extra) + + def test_run_command_passes_fan_parameters(self): + device = FakeFanDevice() + args = parser().parse_args(["run", "fan_pwm_hold", "--duration", "3", "--pwm-hz", "25000", + "--sink-duty-pct", "35.5"]) + execute(args, device) + body = device.calls[0][2] + self.assertEqual(body["workload"], "FAN_PWM_HOLD") + self.assertEqual((body["pwm_hz"], body["sink_duty_pct"], body["duration_ms"]), (25000, 35.5, 3000)) + + def test_run_command_omits_fan_parameters_by_default(self): + device = FakeFanDevice() + execute(parser().parse_args(["run", "IDLE", "--duration", "1"]), device) + self.assertNotIn("pwm_hz", device.calls[0][2]) + self.assertNotIn("sink_duty_pct", device.calls[0][2]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_identity_provenance.py b/tests/test_identity_provenance.py new file mode 100644 index 0000000..7d77756 --- /dev/null +++ b/tests/test_identity_provenance.py @@ -0,0 +1,132 @@ +"""Artifacts must say what produced them: build, board profile, experiment +profile, and device, plus a truthful source for every measured quantity.""" + +import re +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +from cli.dragonbench.main import execute, parser + +ROOT = Path(__file__).parents[1] +MAIN_C = ROOT / "firmware/targets/esp32s3/main/main.c" +PROVENANCE_CMAKE = ROOT / "firmware/targets/esp32s3/main/build_provenance.cmake" + + +def _function_body(source, signature): + start = source.index(signature) + return source[start:source.index("\n}\n", start)] + + +class IdentityTests(unittest.TestCase): + def test_device_identity_names_build_board_experiment_and_device(self): + identity = _function_body(MAIN_C.read_text(), "static cJSON *identity_json") + self.assertIn('"board_profile", CONFIG_DB_TARGET_NAME', identity) + self.assertIn('"experiment_profile", DB_EXPERIMENT_PROFILE_NAME', identity) + self.assertIn('"device_id", device_id', identity) + self.assertIn("add_build(o);", identity) + build = _function_body(MAIN_C.read_text(), "static void add_build") + self.assertIn('cJSON_AddNullToObject(b, "git_sha")', build) + self.assertIn('"source_tree", DB_BUILD_SOURCE_TREE', build) + + def test_boot_event_carries_image_identity(self): + source = MAIN_C.read_text() + boot = source[source.index("char image[192];"):source.index('emit_event("boot"')] + for field in ("board_profile", "experiment_profile", "git_sha", "source_tree"): + self.assertIn(field, boot) + self.assertIn('emit_event("boot", NULL, prior_run, NULL, image, NULL);', source) + + def test_provenance_header_is_regenerated_every_build(self): + cmake = (ROOT / "firmware/targets/esp32s3/main/CMakeLists.txt").read_text() + self.assertIn("add_custom_target(db_build_provenance", cmake) + self.assertIn("add_dependencies(${COMPONENT_LIB} db_build_provenance)", cmake) + self.assertIn('#include "db_build_provenance.h"', MAIN_C.read_text()) + + def test_host_artifacts_include_device_identity(self): + class Fake: + def __init__(self): self.paths = [] + def request(self, method, path, body=None): + self.paths.append(path) + if path == "/api/v1/device": return {"experiment_profile": "baseline"} + if path == "/api/v1/events": return [] + return {"run_id": "r"} + fake = Fake() + exported = execute(parser().parse_args(["export", "r"]), fake) + self.assertEqual(exported["device"], {"experiment_profile": "baseline"}) + + +@unittest.skipUnless(shutil.which("cmake") and shutil.which("git"), "needs cmake and git") +class BuildProvenanceScriptTests(unittest.TestCase): + def run_script(self, source_dir, output): + subprocess.run(["cmake", f"-DSOURCE_DIR={source_dir}", f"-DOUTPUT={output}", "-P", str(PROVENANCE_CMAKE)], + check=True, capture_output=True) + text = output.read_text() + sha = re.search(r'#define DB_BUILD_GIT_SHA "([^"]*)"', text).group(1) + tree = re.search(r'#define DB_BUILD_SOURCE_TREE "([^"]*)"', text).group(1) + return sha, tree + + def test_reports_clean_dirty_and_unknown_truthfully(self): + with tempfile.TemporaryDirectory() as tmp: + repo, header = Path(tmp) / "repo", Path(tmp) / "provenance.h" + repo.mkdir() + git = ["git", "-C", str(repo), "-c", "user.email=bench@example.invalid", "-c", "user.name=bench"] + subprocess.run(git + ["init", "-q"], check=True) + (repo / "f.c").write_text("int x;\n") + subprocess.run(git + ["add", "f.c"], check=True) + subprocess.run(git + ["commit", "-qm", "init"], check=True) + head = subprocess.run(git + ["rev-parse", "HEAD"], check=True, capture_output=True, text=True).stdout.strip() + + self.assertEqual(self.run_script(repo, header), (head, "clean")) + before = header.stat().st_mtime_ns + self.assertEqual(self.run_script(repo, header), (head, "clean")) + self.assertEqual(header.stat().st_mtime_ns, before, "unchanged provenance must not rewrite the header") + + (repo / "f.c").write_text("int y;\n") + self.assertEqual(self.run_script(repo, header), (head, "dirty")) + (repo / "f.c").write_text("int x;\n") + (repo / "new.c").write_text("int z;\n") + self.assertEqual(self.run_script(repo, header), (head, "dirty")) + + plain = Path(tmp) / "not-a-repo" + plain.mkdir() + self.assertEqual(self.run_script(plain, header), ("", "unknown")) + + +class MeasurementProvenanceTests(unittest.TestCase): + def setUp(self): + self.body = _function_body(MAIN_C.read_text(), "static void add_measurement_provenance") + self.pairs = re.findall(r'cJSON_AddStringToObject\(m, "(\w+)",\s*"([^"]+)"\)', self.body) + + def test_measurement_authority_claim_is_gone(self): + for path in (MAIN_C, ROOT / "web/index.html", ROOT / "protocol/openapi.yaml"): + self.assertNotIn("measurement_authority", path.read_text(), path) + + def test_every_source_is_dut_derived_or_external(self): + for quantity, source in self.pairs: + self.assertRegex(source, r"^(external|dut\.esp32s3_\w+|derived\.\w+)$", quantity) + self.assertIn('"fan_speed",\n fan_characterization_ppr() ? ' + '"derived.fan_tach_edges_and_configured_ppr" : "external"', self.body) + + def test_dut_claims_match_what_firmware_acquires(self): + sources = dict(self.pairs) + self.assertEqual(sources["soc_temperature"], "dut.esp32s3_temperature_sensor") + self.assertEqual(sources["wifi_rssi"], "dut.esp32s3_wifi") + self.assertEqual(sources["fan_tach_edges"], "dut.esp32s3_pcnt") + for external in ("supply_voltage", "supply_current", "rail_voltage", "reset_and_brownout", + "fan_airflow", "fan_temperature", "fan_supply_current", "fan_supply_voltage", + "fan_pwm_line_waveform"): + self.assertEqual(sources[external], "external", external) + # The on-board supply sensors really are absent. + self.assertIn('"supply_voltage", "MCU supply voltage", "V", "none", "unsupported"', MAIN_C.read_text()) + + def test_fan_quantities_exist_only_in_the_fan_profile(self): + guard = self.body.index("#if DB_EXPERIMENT_FAN_CHARACTERIZATION") + end = self.body.index("#endif", guard) + for match in re.finditer(r'"(fan_\w+)"', self.body): + self.assertTrue(guard < match.start() < end, match.group(1)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_status_rgb.py b/tests/test_status_rgb.py new file mode 100644 index 0000000..33699ce --- /dev/null +++ b/tests/test_status_rgb.py @@ -0,0 +1,137 @@ +"""The board status RGB LED: board-owned, green only when ready and idle. + +The LED is load on the module supply, so it is off before any run's +measurement window opens and stays off until that run ends. The on/off policy +itself (db_status_ready) is exercised natively in tests/native/test_db_run.c; +these checks pin down where the firmware applies it and what the driver may do. +""" + +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).parents[1] +MAIN_C = ROOT / "firmware/targets/esp32s3/main/main.c" +DRIVER_C = ROOT / "firmware/targets/esp32s3/main/status_rgb.c" +KCONFIG = ROOT / "firmware/targets/esp32s3/main/Kconfig.projbuild" + + +def _function_body(source, signature): + start = source.index(signature) + return source[start:source.index("\n}\n", start)] + + +def _locked_sections(body): + """Each state_lock critical section in body, in order.""" + return re.findall(r"xSemaphoreTake\(state_lock, portMAX_DELAY\);(.*?)xSemaphoreGive\(state_lock\);", body, re.S) + + +class LifecycleTests(unittest.TestCase): + def setUp(self): + self.main = MAIN_C.read_text() + + def test_led_is_driven_only_from_the_state_sync(self): + # One place turns the LED on or off, and it follows db_status_ready. + self.assertEqual(self.main.count("status_rgb_set("), 1) + sync = _function_body(self.main, "static void status_sync_locked(void)") + self.assertIn("db_status_ready(device_ready, ¤t_run)", sync) + self.assertIn("status_rgb_set(green)", sync) + for workload in ("static bool run_cpu", "static bool run_network", "static bool run_nvs", + "static bool partition_cycle", "static bool wait_abortable"): + self.assertNotIn("status_", _function_body(self.main, workload), workload) + self.assertNotIn("status_", (ROOT / "firmware/targets/esp32s3/main/fan_characterization.c").read_text()) + + def test_every_sync_runs_under_state_lock(self): + calls = [m.start() for m in re.finditer(r"status_sync_locked\(\);", self.main)] + self.assertEqual(len(calls), 4) + for call in calls: + function_start = self.main.rfind("\n}\n", 0, call) + self.assertGreater(self.main.rfind("xSemaphoreTake(state_lock", 0, call), function_start, call) + # The next lock operation after the call releases the lock it runs under. + after = self.main[call:] + self.assertLess(after.index("xSemaphoreGive(state_lock);"), + after.find("xSemaphoreTake(state_lock") % (len(after) + 1), call) + + def test_boot_starts_with_the_led_off_and_green_only_after_ready(self): + app_main = _function_body(self.main, "void app_main(void)") + init = app_main.index("status_rgb_init();") + self.assertLess(init, app_main.index('emit_event("boot"')) + self.assertLess(init, app_main.index("start_wifi()")) + ready = app_main.index('emit_event("ready"') + section = app_main[ready:] + self.assertIn("device_ready = true;", _locked_sections(section)[0]) + self.assertIn("status_sync_locked();", _locked_sections(section)[0]) + # Nothing before the ready event may mark the device ready. + self.assertEqual(self.main.count("device_ready = true;"), 1) + self.assertNotIn("device_ready = true;", app_main[:ready]) + # A failed access point returns before ready: the LED stays off. + self.assertLess(app_main.index("return;"), ready) + + def test_led_is_off_before_the_run_starts(self): + post = _function_body(self.main, "static esp_err_t runs_post(httpd_req_t *req)") + begin = post.index("db_run_begin(¤t_run") + sync = post.index("status_sync_locked();", begin) + self.assertLess(sync, post.index("xSemaphoreGive(state_lock);", begin)) + self.assertLess(sync, post.index("xTaskCreate(workload_task")) + # The measurement window opens in the workload task, at phase_start. + task = _function_body(self.main, "static void workload_task(void *unused)") + self.assertNotIn("status_sync_locked", task[:task.index('emit_event("phase_start"')]) + + def test_led_returns_only_after_the_measurement_ends(self): + task = _function_body(self.main, "static void workload_task(void *unused)") + finish = task.index("db_run_finish(¤t_run, result") + self.assertLess(task.index('emit_event("phase_end"'), finish) + self.assertEqual(task.count("status_sync_locked();"), 1) + self.assertGreater(task.index("status_sync_locked();"), finish) + # A failed task creation finishes the run as fail, so the LED stays off. + post = _function_body(self.main, "static esp_err_t runs_post(httpd_req_t *req)") + failed = post[post.index("!= pdPASS"):] + self.assertLess(failed.index('db_run_finish(¤t_run, "fail"'), failed.index("status_sync_locked();")) + + def test_policy_is_shared_by_every_experiment_profile(self): + sync = _function_body(self.main, "static void status_sync_locked(void)") + self.assertNotIn("DB_EXPERIMENT", sync) + run_c = (ROOT / "firmware/common/db_run.c").read_text() + policy = _function_body(run_c, "bool db_status_ready(") + self.assertNotIn("DB_EXPERIMENT", policy) + self.assertIn('strcmp(run->result, "pass") == 0', policy) + + +class DriverTests(unittest.TestCase): + def setUp(self): + self.driver = DRIVER_C.read_text() + + def test_driver_is_small_and_quiet(self): + # Solid green or off: no task, timer, animation, heap use, or run-time logging. + for forbidden in ("xTaskCreate", "esp_timer", "vTaskDelay", "malloc", "heap_caps", "ledc_", + "pcnt_", "loop_count = -1"): + self.assertNotIn(forbidden, self.driver, forbidden) + self.assertEqual(self.driver.count("ESP_LOG"), 1) + self.assertIn("ESP_LOGW", _function_body(self.driver, "void status_rgb_init(void)")) + self.assertEqual(re.findall(r"#define GREEN_LEVEL (\d+)", self.driver), ["16"]) + self.assertIn("const uint8_t grb[3] = {green, 0, 0};", self.driver) + + def test_driver_waits_for_the_led_to_latch(self): + send = _function_body(self.driver, "static void send_frame(uint8_t green)") + self.assertIn("rmt_tx_wait_all_done(channel", send) + self.assertIn("esp_rom_delay_us(LATCH_US)", send) + self.assertGreater(int(re.search(r"#define LATCH_US (\d+)", self.driver).group(1)), 280) + + def test_gpio_powered_led_is_unpowered_when_off(self): + body = _function_body(self.driver, "void status_rgb_set(bool green)") + off = body[body.index("} else {"):] + self.assertLess(off.index("send_frame(0);"), off.index("set_power(false);")) + init = _function_body(self.driver, "void status_rgb_init(void)") + self.assertLess(init.index("set_power(false);"), init.index("rmt_new_tx_channel")) + + def test_resources_are_board_settings_not_experiment_settings(self): + kconfig = KCONFIG.read_text() + experiment = kconfig[kconfig.index("if DB_EXPERIMENT_FAN_CHARACTERIZATION\n"):] + for name in ("DB_STATUS_RGB_GPIO", "DB_STATUS_RGB_POWER_GPIO", "DB_STATUS_RGB_POWER_ACTIVE_LEVEL"): + self.assertIn(f"config {name}\n", kconfig) + self.assertNotIn(f"config {name}\n", experiment) + self.assertNotIn("DB_BOARD_RESERVED_GPIO", kconfig) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/index.html b/web/index.html index 7993390..3aaaa52 100644 --- a/web/index.html +++ b/web/index.html @@ -83,7 +83,7 @@

DragonBench

@@ -95,6 +95,10 @@

DragonBench characterization image

Fan-control capability ABSENT +
+ Bench fan-PWM stimulus + ABSENT +
@@ -165,11 +169,14 @@

Workloads

var net=data.network_connected; grid.appendChild(metricTile('Network',net===true?'connected':net===false?'disconnected':undefined,net===true?'connected':net===false?'disconnected':undefined)); grid.appendChild(metricTile('Run ID',data.run_id)); - grid.appendChild(metricTile('Measurement authority',data.measurement_authority)); + grid.appendChild(metricTile('Experiment profile',data.experiment_profile)); var heater=role('capability-heater'),fan=role('capability-fan'); if(typeof data.heater_capability==='boolean'){heater.textContent=data.heater_capability?'PRESENT':'ABSENT';heater.dataset.status=data.heater_capability?'error':'unsupported'} if(typeof data.fan_control_capability==='boolean'){fan.textContent=data.fan_control_capability?'PRESENT':'ABSENT';fan.dataset.status=data.fan_control_capability?'error':'unsupported'} - role('identity').textContent=(data.target||'target —')+' · firmware '+(data.firmware_version||'—'); + var stimulus=role('capability-fan-stimulus'),bench=data.bench_stimulus; + if(bench&&typeof bench.fan_pwm_fixture==='boolean'){stimulus.textContent=bench.fan_pwm_fixture?'BENCH FIXTURE':'ABSENT';stimulus.dataset.status=bench.fan_pwm_fixture?'running':'unsupported'} + var build=data.build||{},sha=typeof build.git_sha==='string'?build.git_sha.slice(0,12):'unknown'; + role('identity').textContent=(data.board_profile||data.target||'target —')+' · '+(data.experiment_profile||'profile —')+' · firmware '+(data.firmware_version||'—')+' · '+sha+(build.source_tree==='dirty'?' (dirty)':''); } function renderSensors(data){ var list=role('sensor-fields');clear(list);