From 93b97f4b913bfe3ecff8d68a171751813463a61b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 21:46:20 +0000 Subject: [PATCH 01/16] firmware: add bench fan-fixture stimulus lifecycle and tach math Platform-neutral core for a bench-only FAN_PWM_HOLD workload: parameter validation (pwm_hz 10..50000, sink_duty_pct 0..100 in 0.1 steps, explicit duration), LEDC duty planning, tach edge frequency, and RPM only from an explicitly configured PPR. db_fan_hold() drives hardware through a small ops seam and releases the stimulus line on its single exit path: run end, abort, and every failure. The workload is registered in every build but supported only when CONFIG_DB_FAN_FIXTURE is set, so normal profiles report and reject it. Host tests cover both profiles, release on boot/end/abort/error, unknown PPR, and the phase_end event-size budget. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XaHXL38BsvntY8t344vv45 --- firmware/common/CMakeLists.txt | 2 +- firmware/common/db_fan.c | 178 +++++++++++++++++ firmware/common/db_run.c | 22 +- firmware/common/include/db_fan.h | 111 +++++++++++ firmware/common/include/db_run.h | 6 + tests/native/README.md | 17 +- tests/native/test_db_fan.c | 332 +++++++++++++++++++++++++++++++ 7 files changed, 662 insertions(+), 6 deletions(-) create mode 100644 firmware/common/db_fan.c create mode 100644 firmware/common/include/db_fan.h create mode 100644 tests/native/test_db_fan.c 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..b5bb393 --- /dev/null +++ b/firmware/common/db_fan.c @@ -0,0 +1,178 @@ +#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; +} + +bool db_fan_pwm_plan(uint32_t pwm_hz, uint16_t sink_duty_tenths_pct, db_fan_pwm_plan_t *plan) { + if (!plan || !db_fan_pwm_hz_valid(pwm_hz) || !db_fan_sink_duty_valid(sink_duty_tenths_pct)) return false; + 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; + plan->resolution_bits = bits; + plan->duty_counts = (uint32_t)((sink_duty_tenths_pct * full_scale + DB_FAN_SINK_DUTY_TENTHS_MAX / 2U) / + DB_FAN_SINK_DUTY_TENTHS_MAX); + plan->applied_milli_pct = (uint32_t)((plan->duty_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; +} + +bool db_fan_boot_release(const db_fan_ops_t *ops) { + return ops && ops->release && ops->release(ops->ctx); +} + +db_fan_hold_outcome_t db_fan_hold(const db_fan_ops_t *ops, 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 (!ops) { + result->error = "no_fixture"; + return DB_FAN_HOLD_FAIL; + } + db_fan_hold_outcome_t outcome = DB_FAN_HOLD_PASS; + if (duration_ms == 0 || !db_fan_pwm_plan(pwm_hz, sink_duty_tenths_pct, &result->plan)) { + 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 (!ops->release(ops->ctx)) { + result->error = "pre_release_failed"; + outcome = DB_FAN_HOLD_FAIL; + goto release; + } + if (!ops->apply(ops->ctx, pwm_hz, &result->plan)) { + result->error = "apply_failed"; + outcome = DB_FAN_HOLD_FAIL; + goto release; + } + 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 = ops->release && ops->release(ops->ctx); + 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_pwm_plan_t *plan) { + if (!out || out_len == 0) return false; + int written; + if (plan && plan->resolution_bits) { + written = snprintf(out, out_len, + "{\"pwm_hz\":%" PRIu32 ",\"sink_duty_pct\":%u.%u,\"duration_ms\":%" PRIu32 + ",\"applied_sink_duty_pct\":%" PRIu32 ".%03" PRIu32 ",\"duty_resolution_bits\":%" PRIu32 "}", + pwm_hz, sink_duty_tenths_pct / 10U, sink_duty_tenths_pct % 10U, duration_ms, + plan->applied_milli_pct / 1000U, plan->applied_milli_pct % 1000U, plan->resolution_bits); + } else { + written = snprintf(out, out_len, "{\"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); + } + return written > 0 && (size_t)written < out_len; +} + +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..0d6d1c7 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_FAN_FIXTURE_BUILD; 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 a fan-fixture build"); + 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)) { diff --git a/firmware/common/include/db_fan.h b/firmware/common/include/db_fan.h new file mode 100644 index 0000000..444415a --- /dev/null +++ b/firmware/common/include/db_fan.h @@ -0,0 +1,111 @@ +#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_fixture.c, behind db_fan_ops_t. + +#include +#include +#include + +#if defined(ESP_PLATFORM) +#include "sdkconfig.h" +#endif + +// Normal profiles leave CONFIG_DB_FAN_FIXTURE unset; only the fan-fixture +// overlay (sdkconfig.defaults.fanfixture) turns the stimulus on. +#if defined(CONFIG_DB_FAN_FIXTURE) && CONFIG_DB_FAN_FIXTURE +#define DB_FAN_FIXTURE_BUILD 1 +#else +#define DB_FAN_FIXTURE_BUILD 0 +#endif + +// 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 + +typedef struct { + uint32_t resolution_bits; + uint32_t duty_counts; // LEDC compare value, 0..2^resolution_bits + uint32_t applied_milli_pct; // sink duty actually programmed, 0..100000 +} db_fan_pwm_plan_t; + +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_pwm_plan(uint32_t pwm_hz, uint16_t sink_duty_tenths_pct, db_fan_pwm_plan_t *plan); + +// 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. release() must drive the gate to the level that turns the +// external stage off and verify it; it must be safe to call at any time. +typedef struct { + bool (*release)(void *ctx); + bool (*apply)(void *ctx, uint32_t pwm_hz, const db_fan_pwm_plan_t *plan); + 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 enum { DB_FAN_HOLD_PASS, DB_FAN_HOLD_ABORTED, DB_FAN_HOLD_FAIL } db_fan_hold_outcome_t; + +typedef struct { + db_fan_pwm_plan_t plan; + int64_t window_start_us; + int64_t window_end_us; + int64_t released_us; + int64_t edge_count; + bool tach_valid; + bool released; + const char *error; // failing stage, or NULL +} db_fan_hold_result_t; + +// Boot/default state: release the line before anything else runs. +bool db_fan_boot_release(const db_fan_ops_t *ops); + +// One FAN_PWM_HOLD: release, program the stimulus, capture tach for the hold, +// then release on every exit path (end, abort, and each failure). +db_fan_hold_outcome_t db_fan_hold(const db_fan_ops_t *ops, 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_pwm_plan_t *plan); +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..c0df0c7 100644 --- a/firmware/common/include/db_run.h +++ b/firmware/common/include/db_run.h @@ -21,6 +21,7 @@ typedef enum { DB_NVS_WRITE, DB_OTA_PARTITION_WRITE, DB_CONTROLLED_REBOOT, + DB_FAN_PWM_HOLD, // bench fan-fixture stimulus; supported only in fixture builds 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 { diff --git a/tests/native/README.md b/tests/native/README.md index dda5d37..ea35282 100644 --- a/tests/native/README.md +++ b/tests/native/README.md @@ -1,14 +1,23 @@ # Native common-state test -This test directly exercises the platform-neutral C workload registry, +`test_db_run.c` directly exercises the platform-neutral C workload registry, validation, state transitions, run identity retention, and abort behavior. +`test_db_fan.c` covers the bench fan fixture: parameter bounds, LEDC duty +planning, 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 a normal profile and with +`-DCONFIG_DB_FAN_FIXTURE=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_FAN_FIXTURE=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..e29eeb1 --- /dev/null +++ b/tests/native/test_db_fan.c @@ -0,0 +1,332 @@ +// Fan-fixture common logic. Built twice: as a normal profile, and with +// -DCONFIG_DB_FAN_FIXTURE=1 as the bench fixture profile. + +#include "db_fan.h" +#include "db_run.h" + +#include +#include +#include +#include + +typedef enum { LINE_UNKNOWN, LINE_RELEASED, LINE_DRIVEN } fake_line_t; + +typedef struct { + fake_line_t line; + int64_t clock_us; + int release_calls; + int apply_calls; + int abort_after_polls; // <0: never + int polls; + int failing_release_call; + bool fail_apply; + bool fail_tach_clear; + bool fail_tach_read; + int64_t tach_edges_per_ms; + int64_t tach_origin_us; + uint32_t applied_hz; + db_fan_pwm_plan_t applied_plan; +} fake_fixture_t; + +static bool fake_release(void *ctx) { + fake_fixture_t *fake = ctx; + ++fake->release_calls; + if (fake->failing_release_call == fake->release_calls) return false; + fake->line = LINE_RELEASED; + return true; +} + +static bool fake_apply(void *ctx, uint32_t pwm_hz, const db_fan_pwm_plan_t *plan) { + fake_fixture_t *fake = ctx; + ++fake->apply_calls; + if (fake->fail_apply) { + fake->line = LINE_DRIVEN; // a partial configuration may already drive the pad + return false; + } + fake->applied_hz = pwm_hz; + fake->applied_plan = *plan; + fake->line = LINE_DRIVEN; + return true; +} + +static bool fake_tach_clear(void *ctx) { + fake_fixture_t *fake = ctx; + 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; + 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 db_fan_ops_t fake_ops(fake_fixture_t *fake) { + memset(fake, 0, sizeof(*fake)); + fake->abort_after_polls = -1; + fake->clock_us = 5000000; + return (db_fan_ops_t){fake_release, fake_apply, fake_tach_clear, fake_tach_read, fake_now, + fake_should_abort, fake_sleep, fake}; +} + +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_FAN_FIXTURE_BUILD == 1)); + db_run_request_t request = fan_request(25000, 500, 1000); +#if DB_FAN_FIXTURE_BUILD + assert(db_request_validate(&request, error, sizeof(error))); +#else + assert(!db_request_validate(&request, error, sizeof(error))); + assert(strcmp(error, "FAN_PWM_HOLD requires a fan-fixture build") == 0); + // Every other workload stays available in a normal 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_FAN_FIXTURE_BUILD + 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_pwm_plan(void) { + db_fan_pwm_plan_t plan; + assert(db_fan_pwm_plan(25000, 500, &plan)); + assert(plan.resolution_bits == 11 && plan.duty_counts == 1024 && plan.applied_milli_pct == 50000); + assert(db_fan_pwm_plan(25000, 333, &plan)); + assert(plan.duty_counts == 682 && plan.applied_milli_pct == 33301); + assert(db_fan_pwm_plan(DB_FAN_PWM_HZ_MIN, 1000, &plan)); + assert(plan.resolution_bits == 14 && plan.duty_counts == 16384 && plan.applied_milli_pct == 100000); + assert(db_fan_pwm_plan(DB_FAN_PWM_HZ_MAX, 0, &plan)); + assert(plan.resolution_bits == 10 && plan.duty_counts == 0 && plan.applied_milli_pct == 0); + assert(!db_fan_pwm_plan(DB_FAN_PWM_HZ_MIN - 1, 500, &plan)); + assert(!db_fan_pwm_plan(DB_FAN_PWM_HZ_MAX + 1, 500, &plan)); + assert(!db_fan_pwm_plan(25000, 1001, &plan)); + // Every accepted frequency keeps 0.1 % resolution and a legal LEDC divider, + // 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_pwm_plan(hz, duties[i], &plan)); + assert(plan.resolution_bits >= DB_FAN_MIN_RESOLUTION_BITS && + plan.resolution_bits <= DB_FAN_MAX_RESOLUTION_BITS); + const uint64_t divider_x256 = + (uint64_t)DB_FAN_LEDC_SOURCE_HZ * 256U / ((uint64_t)hz << plan.resolution_bits); + assert(divider_x256 >= 256U && divider_x256 <= DB_FAN_LEDC_DIVIDER_MAX_X256); + const int64_t error_milli = (int64_t)plan.applied_milli_pct - (int64_t)duties[i] * 100; + const int64_t half_count_milli = (100000 >> plan.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 assert_released(const fake_fixture_t *fake, const db_fan_hold_result_t *result) { + assert(fake->line == LINE_RELEASED); + assert(result->released); +} + +static void test_release_on_every_path(void) { + fake_fixture_t fake; + db_fan_ops_t ops = fake_ops(&fake); + db_fan_hold_result_t result; + + // Boot: the default state is released. + assert(db_fan_boot_release(&ops) && fake.line == LINE_RELEASED && fake.apply_calls == 0); + + // Run end. + ops = fake_ops(&fake); + fake.tach_edges_per_ms = 3; + assert(db_fan_hold(&ops, 25000, 355, 2000, &result) == DB_FAN_HOLD_PASS); + assert_released(&fake, &result); + assert(fake.release_calls == 2 && fake.apply_calls == 1 && fake.applied_hz == 25000); + assert(fake.applied_plan.duty_counts == result.plan.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. + ops = fake_ops(&fake); + fake.abort_after_polls = 5; + assert(db_fan_hold(&ops, 25000, 500, 60000, &result) == DB_FAN_HOLD_ABORTED); + assert_released(&fake, &result); + assert(result.tach_valid && !result.error); + assert(result.window_end_us - result.window_start_us == 5 * DB_FAN_ABORT_POLL_MS * 1000); + + // Errors: apply, tach clear, tach read. + ops = fake_ops(&fake); + fake.fail_apply = true; + assert(db_fan_hold(&ops, 25000, 500, 1000, &result) == DB_FAN_HOLD_FAIL); + assert_released(&fake, &result); + assert(strcmp(result.error, "apply_failed") == 0); + + ops = fake_ops(&fake); + fake.fail_tach_clear = true; + assert(db_fan_hold(&ops, 25000, 500, 1000, &result) == DB_FAN_HOLD_FAIL); + assert_released(&fake, &result); + assert(strcmp(result.error, "tach_clear_failed") == 0 && !result.tach_valid); + + ops = fake_ops(&fake); + fake.fail_tach_read = true; + assert(db_fan_hold(&ops, 25000, 500, 1000, &result) == DB_FAN_HOLD_FAIL); + assert_released(&fake, &result); + assert(strcmp(result.error, "tach_read_failed") == 0 && !result.tach_valid); + + // Invalid parameters never reach apply and still release. + ops = fake_ops(&fake); + assert(db_fan_hold(&ops, 5, 500, 1000, &result) == DB_FAN_HOLD_FAIL); + assert_released(&fake, &result); + assert(fake.apply_calls == 0 && strcmp(result.error, "invalid_parameters") == 0); + ops = fake_ops(&fake); + assert(db_fan_hold(&ops, 25000, 500, 0, &result) == DB_FAN_HOLD_FAIL); + assert(fake.apply_calls == 0); + + // A stimulus is never applied unless the line was first released. + ops = fake_ops(&fake); + fake.failing_release_call = 1; + assert(db_fan_hold(&ops, 25000, 500, 1000, &result) == DB_FAN_HOLD_FAIL); + assert(fake.apply_calls == 0 && strcmp(result.error, "pre_release_failed") == 0); + assert_released(&fake, &result); // the exit-path release still ran + + // A release that cannot be verified is a failure even after a clean hold. + ops = fake_ops(&fake); + fake.failing_release_call = 2; + assert(db_fan_hold(&ops, 25000, 500, 1000, &result) == DB_FAN_HOLD_FAIL); + assert(!result.released && strcmp(result.error, "release_failed") == 0); + assert(fake.line == LINE_DRIVEN); + + assert(db_fan_hold(NULL, 25000, 500, 1000, &result) == DB_FAN_HOLD_FAIL); + assert(!db_fan_boot_release(NULL)); +} + +// 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_pwm_plan_t plan; + assert(db_fan_pwm_plan(DB_FAN_PWM_HZ_MAX, 999, &plan)); + char parameters[DB_FAN_PARAMETERS_JSON_LEN]; + assert(db_fan_format_parameters(parameters, sizeof(parameters), DB_FAN_PWM_HZ_MAX, 999, 3600000, &plan)); + 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_pwm_plan(); + test_tach_math(); + test_metrics_unknown_and_known_ppr(); + test_release_on_every_path(); + test_phase_end_fits_event_slot(); + printf("db_fan tests passed (%s profile)\n", DB_FAN_FIXTURE_BUILD ? "fan-fixture" : "normal"); + return 0; +} From 80099a8dcb11dc6e1eb21b246b2b63cc1908599a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 21:46:21 +0000 Subject: [PATCH 02/16] firmware: add ESP32-S3 bench fan-fixture profile CONFIG_DB_FAN_FIXTURE (off in every normal profile) compiles an LEDC stimulus that drives only the gate of an external open-drain stage and a PCNT falling-edge tach counter. Gate GPIO, gate sink level, and tach GPIO have no defaults: an unconfigured fixture build stops with an #error per missing setting, and reserved or colliding pins also fail the build. A non-zero PPR requires an evidence reference. The gate is released first in app_main, before every hold, on every hold exit, and from a shutdown handler. Release moves the pad back to a plain GPIO output and reads it back. heater_capability and fan_control_capability stay false in every build. The new bench_stimulus object and landing-page chip report the fixture separately from product control. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XaHXL38BsvntY8t344vv45 --- .gitignore | 3 + firmware/targets/esp32s3/main/CMakeLists.txt | 4 +- .../targets/esp32s3/main/Kconfig.projbuild | 61 +++++ firmware/targets/esp32s3/main/fan_fixture.c | 243 ++++++++++++++++++ firmware/targets/esp32s3/main/fan_fixture.h | 24 ++ firmware/targets/esp32s3/main/main.c | 81 +++++- sdkconfig.defaults.fanfixture | 7 + web/index.html | 8 +- 8 files changed, 420 insertions(+), 11 deletions(-) create mode 100644 firmware/targets/esp32s3/main/fan_fixture.c create mode 100644 firmware/targets/esp32s3/main/fan_fixture.h create mode 100644 sdkconfig.defaults.fanfixture diff --git a/.gitignore b/.gitignore index 5fbbc68..aaace40 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ __pycache__/ .venv/ .pytest_cache/ artifacts/ +sdkconfig.fanfixture.local +test_db_fan +test_db_fan.exe diff --git a/firmware/targets/esp32s3/main/CMakeLists.txt b/firmware/targets/esp32s3/main/CMakeLists.txt index f669924..1e666ad 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_fixture.c" INCLUDE_DIRS "." REQUIRES common @@ -13,6 +13,8 @@ idf_component_register( esp_partition esp_driver_tsens esp_driver_gpio + esp_driver_ledc + esp_driver_pcnt esp_timer json ) diff --git a/firmware/targets/esp32s3/main/Kconfig.projbuild b/firmware/targets/esp32s3/main/Kconfig.projbuild index 050828a..6afd2c5 100644 --- a/firmware/targets/esp32s3/main/Kconfig.projbuild +++ b/firmware/targets/esp32s3/main/Kconfig.projbuild @@ -30,4 +30,65 @@ 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_FAN_FIXTURE + bool "Bench fan-characterization fixture (NOT product fan control)" + default n + 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. Normal profiles leave this off; + enable it only with sdkconfig.defaults.fanfixture plus a local + overlay that states the wired GPIOs. See docs/FAN_FIXTURE.md. + +if DB_FAN_FIXTURE +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. +endif endmenu diff --git a/firmware/targets/esp32s3/main/fan_fixture.c b/firmware/targets/esp32s3/main/fan_fixture.c new file mode 100644 index 0000000..7948d9e --- /dev/null +++ b/firmware/targets/esp32s3/main/fan_fixture.c @@ -0,0 +1,243 @@ +#include "fan_fixture.h" + +#if DB_FAN_FIXTURE_BUILD + +#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 fixture: set CONFIG_DB_FAN_PWM_GATE_GPIO to the GPIO wired to the external open-drain stage gate (docs/FAN_FIXTURE.md)" +#endif +#if CONFIG_DB_FAN_GATE_SINK_LEVEL < 0 +#error "Fan fixture: 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_FIXTURE.md)" +#endif +#if CONFIG_DB_FAN_TACH_GPIO < 0 +#error "Fan fixture: set CONFIG_DB_FAN_TACH_GPIO to the GPIO wired to the conditioned tach signal (docs/FAN_FIXTURE.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 fixture: 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 fixture: 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 fixture: 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 fixture: GPIO collides with CONFIG_DB_RF_SWITCH_GPIO" +#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 fixture: 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_fixture" +#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_DRIVEN, LINE_RELEASE_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_DRIVEN: return "driven"; + case LINE_RELEASE_FAILED: return "release_failed"; + default: return "unconfigured"; + } +} + +// Detaches the gate from LEDC by routing it back to a plain GPIO output at the +// release level (output register first, so the switch cannot glitch to sink), +// then reads the pad back. No internal pulls: the external gate resistor owns +// the level whenever the pin is not driven, including reset and boot. +static bool release_line(void *ctx) { + (void)ctx; + gpio_set_level(GATE_GPIO, GATE_RELEASE_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, + }; + bool ok = gpio_config(&gate) == ESP_OK && gpio_set_level(GATE_GPIO, GATE_RELEASE_LEVEL) == ESP_OK && + gpio_get_level(GATE_GPIO) == GATE_RELEASE_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; + } + line_state = ok ? LINE_RELEASED : LINE_RELEASE_FAILED; + return ok; +} + +// LEDC duty is the fraction of each period the stage sinks the line. An +// inverting stage (sink at gate low) inverts the pad instead of the duty. +static bool apply_stimulus(void *ctx, uint32_t pwm_hz, const db_fan_pwm_plan_t *plan) { + (void)ctx; + const ledc_timer_config_t timer = { + .speed_mode = FAN_LEDC_MODE, + .duty_resolution = (ledc_timer_bit_t)plan->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 = plan->duty_counts, + .hpoint = 0, + .flags.output_invert = GATE_SINK_LEVEL ? 0 : 1, + }; + ledc_channel_active = true; + if (ledc_channel_config(&channel) != ESP_OK) return false; + line_state = LINE_DRIVEN; + return true; +} + +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 = { + .release = release_line, + .apply = apply_stimulus, + .tach_clear = tach_clear, + .tach_read = tach_read, + .now_us = now_us, + .should_abort = hold_should_abort, + .sleep_ms = sleep_ms, +}; + +// 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) { release_line(NULL); } + +void fan_fixture_boot(void) { + if (!db_fan_boot_release(&ops)) 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, "BENCH FAN FIXTURE BUILD: 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_fixture_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(&ops, request->pwm_hz, request->sink_duty_tenths_pct, request->duration_ms, result); + abort_requested = NULL; + return outcome; +} + +uint32_t fan_fixture_ppr(void) { return CONFIG_DB_FAN_TACH_PPR; } + +void fan_fixture_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_fixture.h b/firmware/targets/esp32s3/main/fan_fixture.h new file mode 100644 index 0000000..b469473 --- /dev/null +++ b/firmware/targets/esp32s3/main/fan_fixture.h @@ -0,0 +1,24 @@ +#pragma once + +// ESP32-S3 backend for the bench-only fan-characterization fixture. Compiled +// only when CONFIG_DB_FAN_FIXTURE is set; normal profiles contain none of it. + +#include "db_fan.h" +#include "db_run.h" + +#if DB_FAN_FIXTURE_BUILD +#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_fixture_boot(void); + +db_fan_hold_outcome_t fan_fixture_hold(const db_run_request_t *request, bool (*should_abort)(void), + db_fan_hold_result_t *result); + +// Configured pulses per revolution; 0 means unknown and RPM is never derived. +uint32_t fan_fixture_ppr(void); + +// Adds the static fixture configuration and live line state to a device object. +void fan_fixture_describe(cJSON *parent); +#endif diff --git a/firmware/targets/esp32s3/main/main.c b/firmware/targets/esp32s3/main/main.c index f869aa5..51d83bd 100644 --- a/firmware/targets/esp32s3/main/main.c +++ b/firmware/targets/esp32s3/main/main.c @@ -7,8 +7,10 @@ #include #include "cJSON.h" +#include "db_fan.h" #include "db_network.h" #include "db_run.h" +#include "fan_fixture.h" #include "driver/gpio.h" #include "driver/temperature_sensor.h" #include "esp_attr.h" @@ -261,6 +263,20 @@ 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_pwm_plan_t plan = {0}; + const bool planned = db_fan_pwm_plan(request->pwm_hz, request->sink_duty_tenths_pct, &plan); + db_fan_format_parameters(out, out_len, request->pwm_hz, request->sink_duty_tenths_pct, + request->duration_ms, planned ? &plan : 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); +} + static void workload_task(void *unused) { (void)unused; db_run_t run; @@ -269,13 +285,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,12 +317,24 @@ static void workload_task(void *unused) { } case DB_CONTROLLED_REBOOT: ok = wait_abortable(run.request.duration_ms); break; +#if DB_FAN_FIXTURE_BUILD + case DB_FAN_PWM_HOLD: { + db_fan_hold_result_t hold; + ok = fan_fixture_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_fixture_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()); @@ -339,8 +366,14 @@ static cJSON *identity_json(void) { cJSON_AddStringToObject(o, "firmware_version", CONFIG_DB_FIRMWARE_VERSION); 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 *bench = cJSON_AddObjectToObject(o, "bench_stimulus"); + cJSON_AddBoolToObject(bench, "fan_pwm_fixture", DB_FAN_FIXTURE_BUILD); +#if DB_FAN_FIXTURE_BUILD + fan_fixture_describe(bench); +#endif cJSON_AddStringToObject(o, "measurement_authority", "external_bench_equipment"); return o; } @@ -412,6 +445,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-fixture build"); + } cJSON_AddItemToArray(a, o); } static const char *const unsupported[] = { @@ -446,6 +483,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 +504,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); @@ -648,7 +702,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. A bench fan-fixture build 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 +713,10 @@ static const char landing[] = " Fan-control capability" " ABSENT" "
" +"
" +" Bench fan-PWM stimulus" +" ABSENT" +"
" "
" "" "
" @@ -727,6 +785,8 @@ static const char landing[] = " 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'}" +" 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'}" " role('identity').textContent=(data.target||'target —')+' · firmware '+(data.firmware_version||'—');" " }" " function renderSensors(data){" @@ -1154,6 +1214,9 @@ static void antenna_init(void) { } void app_main(void) { +#if DB_FAN_FIXTURE_BUILD + fan_fixture_boot(); // release the stimulus line before anything else +#endif ESP_ERROR_CHECK(nvs_flash_init()); state_lock = xSemaphoreCreateMutex(); boot_nonce = esp_random(); diff --git a/sdkconfig.defaults.fanfixture b/sdkconfig.defaults.fanfixture new file mode 100644 index 0000000..853f935 --- /dev/null +++ b/sdkconfig.defaults.fanfixture @@ -0,0 +1,7 @@ +# Bench-only fan-characterization fixture. NOT product fan control. +# Layer after a board profile, then add a local, untracked overlay that states +# the wired fixture. Every fixture GPIO defaults to -1, so a build without that +# overlay fails with an #error naming the missing setting. Example: +# idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.tinys3d;sdkconfig.defaults.fanfixture;sdkconfig.fanfixture.local" build +# See docs/FAN_FIXTURE.md for what sdkconfig.fanfixture.local must contain. +CONFIG_DB_FAN_FIXTURE=y diff --git a/web/index.html b/web/index.html index 7993390..781f752 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 +
@@ -169,6 +173,8 @@

Workloads

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'} + 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'} role('identity').textContent=(data.target||'target —')+' · firmware '+(data.firmware_version||'—'); } function renderSensors(data){ From 8e688cc1d9025696b425f39f7f5eb779ccae554e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 21:46:21 +0000 Subject: [PATCH 03/16] cli: add fan-sweep and FAN_PWM_HOLD run parameters fan-sweep runs one FAN_PWM_HOLD per sink-duty step in up, down, up-down, or down-up order and rewrites a JSON results file after every step. It refuses a device that does not report a fan-fixture build, stops at the first non-pass step, and aborts the active run on timeout, refusal, or Ctrl-C. Sweep logic lives only on the host. HTTP error responses now surface the device's error message. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XaHXL38BsvntY8t344vv45 --- cli/dragonbench/main.py | 173 ++++++++++++++++++++++++++++++++++ tests/test_fan_sweep.py | 202 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 tests/test_fan_sweep.py diff --git a/cli/dragonbench/main.py b/cli/dragonbench/main.py index 4cc74a5..51614fd 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,160 @@ 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 on a fan-fixture build", + 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("bench_stimulus") or {}).get("fan_pwm_fixture") is not True: + raise ClientError("device is not a fan-fixture build; 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 +273,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 +297,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", {}) diff --git a/tests/test_fan_sweep.py b/tests/test_fan_sweep.py new file mode 100644 index 0000000..f75384c --- /dev/null +++ b/tests/test_fan_sweep.py @@ -0,0 +1,202 @@ +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-fixture build'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, + "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() From 07073adac6909a0c63c39616bc3ddbb8204e2efe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 21:46:21 +0000 Subject: [PATCH 04/16] docs, ci: document the fan fixture and prove its absence CI builds the normal profiles and fails if their ELF links any LEDC, PCNT, or fan-fixture symbol. It checks that the fixture overlay without wiring refuses to build, and runs the native tests as both profiles. OpenAPI, README, and architecture docs now describe the bench stimulus without implying product fan control. docs/FAN_FIXTURE.md records the electrical model, the release guarantees, the metrics semantics, known limitations, and the wiring decisions still open. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XaHXL38BsvntY8t344vv45 --- .github/workflows/ci.yml | 47 ++++-- README.md | 15 +- ci/build-esp32s3.sh | 66 +++++++- docs/ARCHITECTURE.md | 11 +- docs/BENCH_WORKFLOW.md | 5 + docs/FAN_FIXTURE.md | 241 +++++++++++++++++++++++++++++ protocol/openapi.yaml | 64 +++++++- tests/test_fan_fixture_contract.py | 169 ++++++++++++++++++++ 8 files changed, 601 insertions(+), 17 deletions(-) create mode 100644 docs/FAN_FIXTURE.md create mode 100644 tests/test_fan_fixture_contract.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 924c916..e6b3549 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,20 @@ jobs: path: . command: bash ci/build-esp32s3.sh ${{ matrix.profile }} + fan-fixture-refusal: + name: ESP-IDF 5.3.5 / fan fixture refuses unconfigured build + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v6 + - name: Build fixture overlay without wiring + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: v5.3.5 + target: esp32s3 + path: . + command: bash ci/build-esp32s3.sh tinys3d fanfixture-unconfigured + python-host: name: Python host contracts runs-on: ubuntu-latest @@ -56,21 +70,36 @@ jobs: set +e python -m cli.dragonbench run IDLE --duration 0 status=$? + test "$status" -eq 2 + python -m cli.dragonbench fan-sweep --pwm-hz 0 --low-pct 0 --high-pct 10 --step-pct 5 --order up --hold-ms 1000 --output sweep.json + status=$? set -e test "$status" -eq 2 native-c: - name: Native C state machine + name: Native C state machine (${{ matrix.profile }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - profile: normal + defines: "" + - profile: fan-fixture + defines: "-DCONFIG_DB_FAN_FIXTURE=1" steps: - name: Check out repository uses: actions/checkout@v6 - name: Compile with warnings and sanitizers - run: >- - 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-fixture tests + run: | + ./test_db_run + ./test_db_fan diff --git a/README.md b/README.md index ace6d47..56c91b7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,13 @@ 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. +Normal builds contain no heater or fan GPIOs and expose no actuator API. An +explicit, off-by-default [bench fan-fixture profile](docs/FAN_FIXTURE.md) adds +a single stimulus workload, `FAN_PWM_HOLD`, that drives only the gate of an +external open-drain stage and counts tach edges for fan characterization. That +is characterization equipment, not product fan control: no build has a heater +path, a closed loop, thresholds, or fan-safety policy, and +`heater_capability` and `fan_control_capability` are `false` in every build. 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 @@ -83,7 +89,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), -and [protocol contract](protocol/openapi.yaml). +[fan fixture](docs/FAN_FIXTURE.md), and [protocol contract](protocol/openapi.yaml). ## Validation status @@ -109,6 +115,11 @@ yet been exercised on hardware, and workload execution and electrical characterization remain unvalidated there too. See [TinyS3[D] profile](docs/TARGET_TINYS3D.md). +The bench fan-fixture 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 GPIOs are not yet defined, and no part +of it has run 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..f612da4 100644 --- a/ci/build-esp32s3.sh +++ b/ci/build-esp32s3.sh @@ -1,7 +1,14 @@ #!/usr/bin/env bash set -euo pipefail +# Usage: ci/build-esp32s3.sh [n8r8|tinys3d] [fanfixture|fanfixture-unconfigured] +# +# Without a second argument this is a normal profile, which must contain no +# fan-stimulus code. "fanfixture" layers the bench fan-fixture overlay plus the +# untracked sdkconfig.fanfixture.local that states the wired GPIOs. +# "fanfixture-unconfigured" proves that the overlay alone refuses to build. profile="${1:-n8r8}" +fixture="${2:-}" case "$profile" in n8r8) defaults="sdkconfig.defaults" @@ -16,10 +23,44 @@ case "$profile" in exit 2 ;; esac +case "$fixture" in + "") ;; + fanfixture) + if [[ ! -f sdkconfig.fanfixture.local ]]; then + echo "fanfixture needs sdkconfig.fanfixture.local stating the wired fixture (docs/FAN_FIXTURE.md)" >&2 + exit 2 + fi + defaults="$defaults;sdkconfig.defaults.fanfixture;sdkconfig.fanfixture.local" + expect+=("CONFIG_DB_FAN_FIXTURE 1") + ;; + fanfixture-unconfigured) + defaults="$defaults;sdkconfig.defaults.fanfixture" + ;; + *) + echo "unknown fixture option: $fixture (expected fanfixture or fanfixture-unconfigured)" >&2 + exit 2 + ;; +esac rm -f sdkconfig idf.py -D SDKCONFIG_DEFAULTS="$defaults" fullclean idf.py -D SDKCONFIG_DEFAULTS="$defaults" set-target esp32s3 + +if [[ "$fixture" == fanfixture-unconfigured ]]; then + if idf.py -D SDKCONFIG_DEFAULTS="$defaults" build > idf-build.log 2>&1; then + echo "unconfigured fan-fixture build succeeded; it must refuse to build" >&2 + exit 1 + fi + for setting in CONFIG_DB_FAN_PWM_GATE_GPIO CONFIG_DB_FAN_GATE_SINK_LEVEL CONFIG_DB_FAN_TACH_GPIO; do + if ! grep -q "#error \"Fan fixture: set $setting" idf-build.log; then + echo "unconfigured fan-fixture build did not name $setting" >&2 + exit 1 + fi + done + echo "profile $profile unconfigured fan fixture 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 @@ -33,4 +74,27 @@ for line in "${expect[@]}"; do exit 1 fi done -echo "profile $profile verified" + +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 +stimulus_symbols="$(grep -E ' (ledc_|pcnt_|fan_fixture_)' <<< "$symbols" || true)" +if [[ -z "$fixture" ]]; then + if grep -q "CONFIG_DB_FAN_FIXTURE" build/config/sdkconfig.h; then + echo "normal profile $profile enabled the fan fixture" >&2 + exit 1 + fi + if [[ -n "$stimulus_symbols" ]]; then + echo "normal profile $profile links fan-stimulus code:" >&2 + echo "$stimulus_symbols" >&2 + exit 1 + fi +elif ! grep -q ' fan_fixture_hold$' <<< "$stimulus_symbols"; then + echo "fan-fixture profile $profile does not link fan_fixture_hold" >&2 + exit 1 +fi +echo "profile $profile${fixture:+ ($fixture)} verified" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index aa24cc7..fe3e23f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -3,12 +3,17 @@ 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_fan.c` holds + the bench fan-fixture stimulus lifecycle and tach arithmetic behind a small + hardware seam, so release-on-every-path is 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_fixture.c` (LEDC + stimulus, PCNT tach) compiles only into the explicit fan-fixture profile; see + [fan fixture](FAN_FIXTURE.md). - `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..c22ba36 100644 --- a/docs/BENCH_WORKFLOW.md +++ b/docs/BENCH_WORKFLOW.md @@ -16,6 +16,11 @@ or electrical pass/fail limits. `OTA_PARTITION_WRITE` erases, writes, and verifi only an inactive OTA slot; it never selects that slot for boot. Controlled reboot is single-shot and never forms a reboot loop. +On a bench fan-fixture build, `FAN_PWM_HOLD` and the host `fan-sweep` command +provide the stimulus and raw tach counts. Wiring, semantics, and limits are in +[fan fixture](FAN_FIXTURE.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/FAN_FIXTURE.md b/docs/FAN_FIXTURE.md new file mode 100644 index 0000000..dc28a9f --- /dev/null +++ b/docs/FAN_FIXTURE.md @@ -0,0 +1,241 @@ +# Bench fan-characterization fixture + +> **BENCH CHARACTERIZATION EQUIPMENT — NOT PRODUCT FAN CONTROL.** This 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`. + +Status: firmware, API, and host tooling are implemented and build-verified; no +part of the fixture has run on hardware. The wiring is still to be defined, see +[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 | +|---|---| +| `CONFIG_DB_FAN_FIXTURE` profile, off in every normal profile | `Kconfig.projbuild`, `sdkconfig.defaults.fanfixture` | +| `FAN_PWM_HOLD` workload | `firmware/common/db_run.c` (registry, validation) | +| Stimulus lifecycle and tach arithmetic, host-tested | `firmware/common/db_fan.c` | +| LEDC stimulus and PCNT tach capture | `firmware/targets/esp32s3/main/fan_fixture.c` | +| `fan-sweep` host command | `cli/dragonbench/main.py` | + +Normal builds report `FAN_PWM_HOLD` as `unsupported` (reason `requires +fan-fixture build`), reject it with HTTP 400, and link no LEDC, PCNT, or +fixture 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 percentage of each PWM period during which 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. +- **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_fixture_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 before it programs any stimulus. If it cannot, no stimulus is applied. | +| 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. | + +To release, the firmware writes the release level to the output register, then +moves the pad from LEDC back to a plain GPIO output, so the switch cannot +glitch toward sink. 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`, 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.fanfixture.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, with the board overlay first: + +```text +idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.tinys3d;sdkconfig.defaults.fanfixture;sdkconfig.fanfixture.local" build +# or, with the same checks CI uses: +bash ci/build-esp32s3.sh tinys3d fanfixture +``` + +Delete a generated `sdkconfig` before switching between fixture and normal +profiles. At boot the serial log prints a `BENCH FAN FIXTURE BUILD` 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. | +| `sink_duty_pct` | 0–100 in 0.1 steps | Quantized to the LEDC resolution; the programmed 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: +release, program LEDC, clear the tach counter, start the window, hold (checking +for abort every 20 ms), read the tach count, end the window, release. 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,"applied_sink_duty_pct":35.498,"duty_resolution_bits":11} +``` + +`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`, + `apply_failed`, `tach_clear_failed`, `tach_read_failed`, or `release_failed`. + +## 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 + `bench_stimulus.fan_pwm_fixture: true`, 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. +- LEDC output, PCNT accumulation, pad readback, and the shutdown handler have + been compiled but 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. Board: TinyS3[D] or N8R8 module for the fixture. +2. Gate GPIO and tach GPIO, chosen from free, non-reserved pins on that board. + Check each pin's reset-state pull in the ESP32-S3 datasheet IO MUX table + against the gate resistor. +3. 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. +4. Fan PWM-lead electrical limits and its internal pull-up (plan §6), and + confirmation that the stage's voltage and current ratings cover them. +5. 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. +6. Lead identity on the delivered specimen (plan §3) and the common-ground + arrangement between the 24 V supply and the ESP32-S3. +7. Glitch-filter setting after scoping the tach line, if one is needed. +8. PPR and the evidence establishing it, only after it has been measured. diff --git a/protocol/openapi.yaml b/protocol/openapi.yaml index 62f9e55..05dc022 100644 --- a/protocol/openapi.yaml +++ b/protocol/openapi.yaml @@ -4,16 +4,74 @@ info: version: 0.1.0 paths: /api/v1/device: - get: {summary: Device identity and immutable product boundary} + get: + summary: Device identity and immutable product boundary + description: >- + 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 a bench fan-characterization fixture build, which can + drive an external open-drain stage that sinks a fan PWM line; that + build also reports bench_stimulus.fan_fixture with its configured + gate/tach GPIOs, gate_sink_level, line_state (released, + driven, release_failed), tach_edge, and tach_ppr (null when unknown). + responses: + "200": + description: Identity + content: + application/json: + schema: + type: object + properties: + 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 when fan_pwm_fixture is true + 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, driven, release_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} /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 a fan-fixture build and "unsupported" (reason "requires + fan-fixture build") otherwise. /api/v1/runs: post: summary: Start one validated workload + description: >- + FAN_PWM_HOLD (fan-fixture builds only) drives the external stage gate + so the fan PWM line is sunk for sink_duty_pct of each period at + pwm_hz, for duration_ms, while PCNT counts tach falling edges. It + requires pwm_hz, sink_duty_pct, and duration_ms, and pwm_hz or + sink_duty_pct on any other workload is rejected. 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, applied_sink_duty_pct, and + 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; a failed release is reported as + "released": false with fixture_error and a fault event. requestBody: required: true content: @@ -27,6 +85,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/tests/test_fan_fixture_contract.py b/tests/test_fan_fixture_contract.py new file mode 100644 index 0000000..59aaaa9 --- /dev/null +++ b/tests/test_fan_fixture_contract.py @@ -0,0 +1,169 @@ +"""Static contract for the bench-only fan-characterization fixture. + +The fixture is characterization equipment: an external open-drain PWM stimulus +and tach capture, compiled only into an explicit fixture profile. These checks +keep it from leaking into normal profiles, from growing product fan control, +and from carrying guessed wiring or fan constants. +""" + +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_fixture.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 NormalProfilesTests(unittest.TestCase): + def test_fixture_is_off_by_default_and_in_every_normal_profile(self): + self.assertEqual(_kconfig_default("DB_FAN_FIXTURE"), "n") + for profile in ("sdkconfig.defaults", "sdkconfig.defaults.tinys3d"): + self.assertNotIn("DB_FAN", _read(profile), profile) + + 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_overlay_only_enables_and_assigns_no_pins(self): + overlay = _read("sdkconfig.defaults.fanfixture") + settings = [line for line in overlay.splitlines() if line and not line.startswith("#")] + self.assertEqual(settings, ["CONFIG_DB_FAN_FIXTURE=y"]) + self.assertIn("sdkconfig.fanfixture.local", _read(".gitignore")) + + def test_unconfigured_fixture_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 fixture: 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_FAN_TACH_PPR_EVIDENCE", source) + + def test_fixture_code_is_compiled_out_of_normal_builds(self): + fixture = _read(FIXTURE_C) + self.assertLess(fixture.index("#if DB_FAN_FIXTURE_BUILD"), fixture.index("driver/ledc.h")) + main = _read(MAIN_C) + for call in ("fan_fixture_boot()", "fan_fixture_hold(", "fan_fixture_describe("): + idx = main.index(call) + # Inside an open "#if DB_FAN_FIXTURE_BUILD" block. + self.assertGreater(main.rfind("#if DB_FAN_FIXTURE_BUILD", 0, idx), main.rfind("#endif", 0, idx), call) + self.assertIn("return DB_FAN_FIXTURE_BUILD;", _read("firmware/common/db_run.c")) + + def test_ci_proves_absence_and_refusal(self): + script = _read("ci/build-esp32s3.sh") + self.assertIn("normal profile $profile links fan-stimulus code", script) + self.assertIn("(ledc_|pcnt_|fan_fixture_)", script) + workflow = _read(".github/workflows/ci.yml") + self.assertIn("fanfixture-unconfigured", workflow) + self.assertIn("-DCONFIG_DB_FAN_FIXTURE=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_FAN_FIXTURE_BUILD") + self.assertLess(heater, guard) + self.assertLess(fan, guard) + self.assertIn('"fan_pwm_fixture", DB_FAN_FIXTURE_BUILD', 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 apply_stimulus", 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("ops->apply("), 1) + self.assertLess(hold.index("ops->apply("), hold.index("ops->tach_read(")) + + 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_fixture_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("ops->release(ops->ctx)", tail) + self.assertNotIn("return", hold[hold.index("ops->apply("):hold.index("\nrelease:")]) + fixture = _read(FIXTURE_C) + self.assertIn("esp_register_shutdown_handler(shutdown_release)", fixture) + self.assertIn("#define GATE_RELEASE_LEVEL (GATE_SINK_LEVEL ? 0 : 1)", fixture) + release = _function_body(fixture, "static bool release_line") + self.assertLess(release.index("gpio_set_level(GATE_GPIO, GATE_RELEASE_LEVEL)"), release.index("gpio_config(")) + self.assertIn("GPIO_PULLUP_DISABLE", release) + + 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() From c4d0fafe24d75c940bb84e6d5dd225668454e810 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:00:45 +0000 Subject: [PATCH 05/16] firmware: model fan stimulus as static release, PWM, or static sink db_fan_pwm_plan() could hand LEDC duty == 2^duty_resolution for 100 % sink, which ESP-IDF 5.3.5 documents as overflowing the ESP32-S3 duty counter. The stimulus is now one of three explicit modes: 0 % holds the gate at the release level, 100 % holds it at the sink level, and only values strictly between use LEDC, with the compare value guaranteed to be 1..2^bits-1. sink_duty_pct keeps its meaning: the fraction of time the external stage sinks the fan PWM line. Events name the mode. The hardware seam is now pad-level (drive_static, start_pwm). The common layer decides which gate level sinks from gate_sink_level, so native tests cover 0 %, PWM, and 100 % for both stage polarities, including the sink fraction the modeled line actually sees. Tach readiness is checked after the pre-release and before any actuation, and the run is refused with tach_unavailable if capture is not available. The counter clear still happens right before the window opens. Release still runs on every exit path. The phase_end worst case outgrew the 512-byte event slot, so the slot is now 640 bytes. emit_event no longer stores a cut-off JSON line: an oversized event drops parameters/metrics and is marked truncated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XaHXL38BsvntY8t344vv45 --- firmware/common/db_fan.c | 105 ++++-- firmware/common/include/db_fan.h | 62 +++- firmware/common/include/db_run.h | 2 +- firmware/targets/esp32s3/main/fan_fixture.c | 65 ++-- firmware/targets/esp32s3/main/main.c | 22 +- protocol/event.schema.json | 1 + tests/native/test_db_fan.c | 361 ++++++++++++++------ tests/test_fan_fixture_contract.py | 38 ++- 8 files changed, 468 insertions(+), 188 deletions(-) diff --git a/firmware/common/db_fan.c b/firmware/common/db_fan.c index b5bb393..1ebae2f 100644 --- a/firmware/common/db_fan.c +++ b/firmware/common/db_fan.c @@ -24,18 +24,41 @@ bool db_fan_sink_duty_from_pct(double sink_duty_pct, uint16_t *tenths_out) { return true; } -bool db_fan_pwm_plan(uint32_t pwm_hz, uint16_t sink_duty_tenths_pct, db_fan_pwm_plan_t *plan) { - if (!plan || !db_fan_pwm_hz_valid(pwm_hz) || !db_fan_sink_duty_valid(sink_duty_tenths_pct)) return false; +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; - plan->resolution_bits = bits; - plan->duty_counts = (uint32_t)((sink_duty_tenths_pct * full_scale + DB_FAN_SINK_DUTY_TENTHS_MAX / 2U) / - DB_FAN_SINK_DUTY_TENTHS_MAX); - plan->applied_milli_pct = (uint32_t)((plan->duty_counts * 100000ULL + full_scale / 2U) / full_scale); + 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; } @@ -54,35 +77,63 @@ bool db_fan_rpm_milli(int64_t edge_count, int64_t window_us, uint32_t ppr, uint6 return true; } -bool db_fan_boot_release(const db_fan_ops_t *ops) { - return ops && ops->release && ops->release(ops->ctx); +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)); } -db_fan_hold_outcome_t db_fan_hold(const db_fan_ops_t *ops, uint32_t pwm_hz, uint16_t sink_duty_tenths_pct, +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 (!ops) { + 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_pwm_plan(pwm_hz, sink_duty_tenths_pct, &result->plan)) { + 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 (!ops->release(ops->ctx)) { + if (!db_fan_release(fixture)) { result->error = "pre_release_failed"; outcome = DB_FAN_HOLD_FAIL; goto release; } - if (!ops->apply(ops->ctx, pwm_hz, &result->plan)) { - result->error = "apply_failed"; + // 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; @@ -110,7 +161,7 @@ db_fan_hold_outcome_t db_fan_hold(const db_fan_ops_t *ops, uint32_t pwm_hz, uint result->window_end_us = ops->now_us(ops->ctx); release: - result->released = ops->release && ops->release(ops->ctx); + 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"; @@ -131,20 +182,20 @@ static bool append(char *out, size_t out_len, size_t *used, const char *format, } 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_pwm_plan_t *plan) { + uint32_t duration_ms, const db_fan_stimulus_t *stimulus) { if (!out || out_len == 0) return false; - int written; - if (plan && plan->resolution_bits) { - written = snprintf(out, out_len, - "{\"pwm_hz\":%" PRIu32 ",\"sink_duty_pct\":%u.%u,\"duration_ms\":%" PRIu32 - ",\"applied_sink_duty_pct\":%" PRIu32 ".%03" PRIu32 ",\"duty_resolution_bits\":%" PRIu32 "}", - pwm_hz, sink_duty_tenths_pct / 10U, sink_duty_tenths_pct % 10U, duration_ms, - plan->applied_milli_pct / 1000U, plan->applied_milli_pct % 1000U, plan->resolution_bits); - } else { - written = snprintf(out, out_len, "{\"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); + 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 written > 0 && (size_t)written < out_len; + 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) { diff --git a/firmware/common/include/db_fan.h b/firmware/common/include/db_fan.h index 444415a..c9c27e3 100644 --- a/firmware/common/include/db_fan.h +++ b/firmware/common/include/db_fan.h @@ -53,18 +53,31 @@ #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 { - uint32_t resolution_bits; - uint32_t duty_counts; // LEDC compare value, 0..2^resolution_bits - uint32_t applied_milli_pct; // sink duty actually programmed, 0..100000 -} db_fan_pwm_plan_t; + 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_pwm_plan(uint32_t pwm_hz, uint16_t sink_duty_tenths_pct, db_fan_pwm_plan_t *plan); +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: @@ -72,11 +85,18 @@ bool db_fan_pwm_plan(uint32_t pwm_hz, uint16_t sink_duty_tenths_pct, db_fan_pwm_ 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. release() must drive the gate to the level that turns the -// external stage off and verify it; it must be safe to call at any time. +// 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 (*release)(void *ctx); - bool (*apply)(void *ctx, uint32_t pwm_hz, const db_fan_pwm_plan_t *plan); + 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); @@ -85,27 +105,37 @@ typedef struct { 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_pwm_plan_t plan; + 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; -// Boot/default state: release the line before anything else runs. -bool db_fan_boot_release(const db_fan_ops_t *ops); +// 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: release, program the stimulus, capture tach for the hold, -// then release on every exit path (end, abort, and each failure). -db_fan_hold_outcome_t db_fan_hold(const db_fan_ops_t *ops, uint32_t pwm_hz, uint16_t sink_duty_tenths_pct, +// 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_pwm_plan_t *plan); + 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 c0df0c7..7bdcf37 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, diff --git a/firmware/targets/esp32s3/main/fan_fixture.c b/firmware/targets/esp32s3/main/fan_fixture.c index 7948d9e..5731dfa 100644 --- a/firmware/targets/esp32s3/main/fan_fixture.c +++ b/firmware/targets/esp32s3/main/fan_fixture.c @@ -61,7 +61,14 @@ _Static_assert(CONFIG_DB_FAN_TACH_PPR == 0 || sizeof(CONFIG_DB_FAN_TACH_PPR_EVID #define FAN_LEDC_CHANNEL LEDC_CHANNEL_0 #define TACH_HIGH_LIMIT 32767 -typedef enum { LINE_UNCONFIGURED, LINE_RELEASED, LINE_DRIVEN, LINE_RELEASE_FAILED } line_state_t; +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; @@ -72,19 +79,21 @@ static bool (*abort_requested)(void); static const char *line_state_name(line_state_t state) { switch (state) { case LINE_RELEASED: return "released"; - case LINE_DRIVEN: return "driven"; + 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"; } } -// Detaches the gate from LEDC by routing it back to a plain GPIO output at the -// release level (output register first, so the switch cannot glitch to sink), -// then reads the pad back. No internal pulls: the external gate resistor owns +// 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 release_line(void *ctx) { +static bool drive_static(void *ctx, int gate_level) { (void)ctx; - gpio_set_level(GATE_GPIO, GATE_RELEASE_LEVEL); + 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, @@ -92,24 +101,26 @@ static bool release_line(void *ctx) { .pull_down_en = GPIO_PULLDOWN_DISABLE, .intr_type = GPIO_INTR_DISABLE, }; - bool ok = gpio_config(&gate) == ESP_OK && gpio_set_level(GATE_GPIO, GATE_RELEASE_LEVEL) == ESP_OK && - gpio_get_level(GATE_GPIO) == GATE_RELEASE_LEVEL; + 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; } - line_state = ok ? LINE_RELEASED : LINE_RELEASE_FAILED; + 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; } -// LEDC duty is the fraction of each period the stage sinks the line. An -// inverting stage (sink at gate low) inverts the pad instead of the duty. -static bool apply_stimulus(void *ctx, uint32_t pwm_hz, const db_fan_pwm_plan_t *plan) { +// 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)plan->resolution_bits, + .duty_resolution = (ledc_timer_bit_t)resolution_bits, .timer_num = FAN_LEDC_TIMER, .freq_hz = pwm_hz, .clk_cfg = LEDC_USE_APB_CLK, @@ -121,16 +132,23 @@ static bool apply_stimulus(void *ctx, uint32_t pwm_hz, const db_fan_pwm_plan_t * .channel = FAN_LEDC_CHANNEL, .intr_type = LEDC_INTR_DISABLE, .timer_sel = FAN_LEDC_TIMER, - .duty = plan->duty_counts, + .duty = duty_counts, .hpoint = 0, - .flags.output_invert = GATE_SINK_LEVEL ? 0 : 1, + .flags.output_invert = invert_output ? 1 : 0, }; ledc_channel_active = true; if (ledc_channel_config(&channel) != ESP_OK) return false; - line_state = LINE_DRIVEN; + 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; @@ -161,8 +179,9 @@ static void sleep_ms(void *ctx, uint32_t ms) { } static const db_fan_ops_t ops = { - .release = release_line, - .apply = apply_stimulus, + .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, @@ -170,6 +189,8 @@ static const db_fan_ops_t ops = { .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) { @@ -194,10 +215,10 @@ static bool tach_init(void) { pcnt_unit_clear_count(tach_unit) == ESP_OK && pcnt_unit_start(tach_unit) == ESP_OK; } -static void shutdown_release(void) { release_line(NULL); } +static void shutdown_release(void) { db_fan_release(&fixture); } void fan_fixture_boot(void) { - if (!db_fan_boot_release(&ops)) ESP_LOGE(TAG, "gate release could not be verified at boot"); + 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"); @@ -209,7 +230,7 @@ db_fan_hold_outcome_t fan_fixture_hold(const db_run_request_t *request, bool (*s db_fan_hold_result_t *result) { abort_requested = should_abort; const db_fan_hold_outcome_t outcome = - db_fan_hold(&ops, request->pwm_hz, request->sink_duty_tenths_pct, request->duration_ms, result); + db_fan_hold(&fixture, request->pwm_hz, request->sink_duty_tenths_pct, request->duration_ms, result); abort_requested = NULL; return outcome; } diff --git a/firmware/targets/esp32s3/main/main.c b/firmware/targets/esp32s3/main/main.c index 51d83bd..489ad5c 100644 --- a/firmware/targets/esp32s3/main/main.c +++ b/firmware/targets/esp32s3/main/main.c @@ -125,14 +125,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); } @@ -265,10 +275,10 @@ static bool run_network(const db_run_request_t *request, uint64_t *tx, uint64_t 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_pwm_plan_t plan = {0}; - const bool planned = db_fan_pwm_plan(request->pwm_hz, request->sink_duty_tenths_pct, &plan); + 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 ? &plan : NULL); + request->duration_ms, planned ? &stimulus : NULL); return; } snprintf(out, out_len, @@ -592,7 +602,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) { diff --git a/protocol/event.schema.json b/protocol/event.schema.json index 5268df7..d9dd2ae 100644 --- a/protocol/event.schema.json +++ b/protocol/event.schema.json @@ -13,6 +13,7 @@ "result": {"enum": ["pass", "fail", "aborted", "unsupported"]}, "parameters": {"type": "object"}, "metrics": {"type": "object"}, + "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-tinys3d"]}, "firmware_version": {"type": "string"} }, diff --git a/tests/native/test_db_fan.c b/tests/native/test_db_fan.c index e29eeb1..e998b2b 100644 --- a/tests/native/test_db_fan.c +++ b/tests/native/test_db_fan.c @@ -9,54 +9,72 @@ #include #include -typedef enum { LINE_UNKNOWN, LINE_RELEASED, LINE_DRIVEN } fake_line_t; - +// 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 { - fake_line_t line; + 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 release_calls; - int apply_calls; - int abort_after_polls; // <0: never + int drive_calls; + int failing_drive_call; // 0: never + int abort_after_polls; // <0: never int polls; - int failing_release_call; - bool fail_apply; + 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; - uint32_t applied_hz; - db_fan_pwm_plan_t applied_plan; + char log[256]; } fake_fixture_t; -static bool fake_release(void *ctx) { +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->release_calls; - if (fake->failing_release_call == fake->release_calls) return false; - fake->line = LINE_RELEASED; + ++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_apply(void *ctx, uint32_t pwm_hz, const db_fan_pwm_plan_t *plan) { +static bool fake_start_pwm(void *ctx, uint32_t pwm_hz, uint32_t bits, uint32_t counts, bool invert) { fake_fixture_t *fake = ctx; - ++fake->apply_calls; - if (fake->fail_apply) { - fake->line = LINE_DRIVEN; // a partial configuration may already drive the pad - return false; - } - fake->applied_hz = pwm_hz; - fake->applied_plan = *plan; - fake->line = LINE_DRIVEN; + 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; @@ -71,12 +89,39 @@ static bool fake_should_abort(void *ctx) { static void fake_sleep(void *ctx, uint32_t ms) { ((fake_fixture_t *)ctx)->clock_us += (int64_t)ms * 1000; } -static db_fan_ops_t fake_ops(fake_fixture_t *fake) { - memset(fake, 0, sizeof(*fake)); - fake->abort_after_polls = -1; - fake->clock_us = 5000000; - return (db_fan_ops_t){fake_release, fake_apply, fake_tach_clear, fake_tach_read, fake_now, - fake_should_abort, fake_sleep, fake}; +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) { @@ -149,32 +194,40 @@ static void test_parameter_bounds(void) { assert(!db_fan_sink_duty_from_pct(INFINITY, &tenths)); } -static void test_pwm_plan(void) { - db_fan_pwm_plan_t plan; - assert(db_fan_pwm_plan(25000, 500, &plan)); - assert(plan.resolution_bits == 11 && plan.duty_counts == 1024 && plan.applied_milli_pct == 50000); - assert(db_fan_pwm_plan(25000, 333, &plan)); - assert(plan.duty_counts == 682 && plan.applied_milli_pct == 33301); - assert(db_fan_pwm_plan(DB_FAN_PWM_HZ_MIN, 1000, &plan)); - assert(plan.resolution_bits == 14 && plan.duty_counts == 16384 && plan.applied_milli_pct == 100000); - assert(db_fan_pwm_plan(DB_FAN_PWM_HZ_MAX, 0, &plan)); - assert(plan.resolution_bits == 10 && plan.duty_counts == 0 && plan.applied_milli_pct == 0); - assert(!db_fan_pwm_plan(DB_FAN_PWM_HZ_MIN - 1, 500, &plan)); - assert(!db_fan_pwm_plan(DB_FAN_PWM_HZ_MAX + 1, 500, &plan)); - assert(!db_fan_pwm_plan(25000, 1001, &plan)); +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, - // and the programmed duty is within half a count of the request. + // 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_pwm_plan(hz, duties[i], &plan)); - assert(plan.resolution_bits >= DB_FAN_MIN_RESOLUTION_BITS && - plan.resolution_bits <= DB_FAN_MAX_RESOLUTION_BITS); + 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 << plan.resolution_bits); + (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); - const int64_t error_milli = (int64_t)plan.applied_milli_pct - (int64_t)duties[i] * 100; - const int64_t half_count_milli = (100000 >> plan.resolution_bits) / 2 + 1; + 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); } } @@ -217,92 +270,181 @@ static void test_metrics_unknown_and_known_ppr(void) { assert(!db_fan_format_metrics(metrics, 20, &result, 0)); } -static void assert_released(const fake_fixture_t *fake, const db_fan_hold_result_t *result) { - assert(fake->line == LINE_RELEASED); - assert(result->released); +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_fixture_t fake; - db_fan_ops_t ops = fake_ops(&fake); + fake_bench_t bench; db_fan_hold_result_t result; - // Boot: the default state is released. - assert(db_fan_boot_release(&ops) && fake.line == LINE_RELEASED && fake.apply_calls == 0); - - // Run end. - ops = fake_ops(&fake); - fake.tach_edges_per_ms = 3; - assert(db_fan_hold(&ops, 25000, 355, 2000, &result) == DB_FAN_HOLD_PASS); - assert_released(&fake, &result); - assert(fake.release_calls == 2 && fake.apply_calls == 1 && fake.applied_hz == 25000); - assert(fake.applied_plan.duty_counts == result.plan.duty_counts); + // 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. - ops = fake_ops(&fake); - fake.abort_after_polls = 5; - assert(db_fan_hold(&ops, 25000, 500, 60000, &result) == DB_FAN_HOLD_ABORTED); - assert_released(&fake, &result); + 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); - // Errors: apply, tach clear, tach read. - ops = fake_ops(&fake); - fake.fail_apply = true; - assert(db_fan_hold(&ops, 25000, 500, 1000, &result) == DB_FAN_HOLD_FAIL); - assert_released(&fake, &result); - assert(strcmp(result.error, "apply_failed") == 0); - - ops = fake_ops(&fake); - fake.fail_tach_clear = true; - assert(db_fan_hold(&ops, 25000, 500, 1000, &result) == DB_FAN_HOLD_FAIL); - assert_released(&fake, &result); + // 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); - ops = fake_ops(&fake); - fake.fail_tach_read = true; - assert(db_fan_hold(&ops, 25000, 500, 1000, &result) == DB_FAN_HOLD_FAIL); - assert_released(&fake, &result); + 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 apply and still release. - ops = fake_ops(&fake); - assert(db_fan_hold(&ops, 5, 500, 1000, &result) == DB_FAN_HOLD_FAIL); - assert_released(&fake, &result); - assert(fake.apply_calls == 0 && strcmp(result.error, "invalid_parameters") == 0); - ops = fake_ops(&fake); - assert(db_fan_hold(&ops, 25000, 500, 0, &result) == DB_FAN_HOLD_FAIL); - assert(fake.apply_calls == 0); - - // A stimulus is never applied unless the line was first released. - ops = fake_ops(&fake); - fake.failing_release_call = 1; - assert(db_fan_hold(&ops, 25000, 500, 1000, &result) == DB_FAN_HOLD_FAIL); - assert(fake.apply_calls == 0 && strcmp(result.error, "pre_release_failed") == 0); - assert_released(&fake, &result); // the exit-path release still ran + // 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. - ops = fake_ops(&fake); - fake.failing_release_call = 2; - assert(db_fan_hold(&ops, 25000, 500, 1000, &result) == DB_FAN_HOLD_FAIL); + 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(fake.line == LINE_DRIVEN); + 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_boot_release(NULL)); + 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_pwm_plan_t plan; - assert(db_fan_pwm_plan(DB_FAN_PWM_HZ_MAX, 999, &plan)); + 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, &plan)); + 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, @@ -322,9 +464,14 @@ static void test_phase_end_fits_event_slot(void) { int main(void) { test_capability_gate(); test_parameter_bounds(); - test_pwm_plan(); + 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 profile)\n", DB_FAN_FIXTURE_BUILD ? "fan-fixture" : "normal"); diff --git a/tests/test_fan_fixture_contract.py b/tests/test_fan_fixture_contract.py index 59aaaa9..34412bf 100644 --- a/tests/test_fan_fixture_contract.py +++ b/tests/test_fan_fixture_contract.py @@ -109,11 +109,12 @@ def test_tach_never_feeds_the_stimulus(self): "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 apply_stimulus", enclosing), helper) + 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("ops->apply("), 1) - self.assertLess(hold.index("ops->apply("), hold.index("ops->tach_read(")) + 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) @@ -123,14 +124,33 @@ def test_release_is_first_in_boot_and_on_every_hold_exit(self): 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("ops->release(ops->ctx)", tail) - self.assertNotIn("return", hold[hold.index("ops->apply("):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) - self.assertIn("#define GATE_RELEASE_LEVEL (GATE_SINK_LEVEL ? 0 : 1)", fixture) - release = _function_body(fixture, "static bool release_line") - self.assertLess(release.index("gpio_set_level(GATE_GPIO, GATE_RELEASE_LEVEL)"), release.index("gpio_config(")) - self.assertIn("GPIO_PULLUP_DISABLE", release) + 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] From fcd7ebd1b5c25d4096e442c36c4f17d4a6cf4ad4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:04:36 +0000 Subject: [PATCH 06/16] firmware: select one experiment profile per image DragonBench images are now one board profile plus one experiment profile, chosen by the mutually exclusive Kconfig choice DB_EXPERIMENT_PROFILE: baseline (default, explicit in sdkconfig.defaults) or fan-characterization. This replaces the independent DB_FAN_FIXTURE flag, so profiles cannot drift into combinable feature switches. - db_experiment.h is the only place that reads the choice; code is compiled under DB_EXPERIMENT_* and the profile name comes from there. - sdkconfig.defaults.fan-characterization selects the profile; wiring lives in the untracked sdkconfig.fan-characterization.local and still has no defaults. The fixture options exist only inside the profile. - fan_fixture.[ch] becomes fan_characterization.[ch]. - /api/v1/device reports experiment_profile, and fan-sweep requires fan-characterization instead of inferring it from capabilities. - ci/build-esp32s3.sh takes [experiment] [--expect-refusal] and checks that every other experiment's code is absent from each ELF. test_experiment_profiles.py enforces the pattern: one choice, one name, one overlay, and one CI entry per profile, and no CONFIG_DB_EXPERIMENT_ reads outside db_experiment.h. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XaHXL38BsvntY8t344vv45 --- .github/workflows/ci.yml | 22 ++-- .gitignore | 2 +- ci/build-esp32s3.sh | 102 +++++++++++------- cli/dragonbench/main.py | 7 +- ...FAN_FIXTURE.md => FAN_CHARACTERIZATION.md} | 0 firmware/common/db_run.c | 4 +- firmware/common/include/db_experiment.h | 26 +++++ firmware/common/include/db_fan.h | 15 +-- firmware/common/include/db_run.h | 2 +- firmware/targets/esp32s3/main/CMakeLists.txt | 2 +- .../targets/esp32s3/main/Kconfig.projbuild | 34 ++++-- .../{fan_fixture.c => fan_characterization.c} | 32 +++--- .../esp32s3/main/fan_characterization.h | 25 +++++ firmware/targets/esp32s3/main/fan_fixture.h | 24 ----- firmware/targets/esp32s3/main/main.c | 24 +++-- sdkconfig.defaults | 3 + sdkconfig.defaults.fan-characterization | 9 ++ sdkconfig.defaults.fanfixture | 7 -- tests/native/README.md | 4 +- tests/native/test_db_fan.c | 16 +-- tests/test_experiment_profiles.py | 70 ++++++++++++ ... => test_fan_characterization_contract.py} | 68 ++++++------ tests/test_fan_sweep.py | 3 +- web/index.html | 2 +- 24 files changed, 324 insertions(+), 179 deletions(-) rename docs/{FAN_FIXTURE.md => FAN_CHARACTERIZATION.md} (100%) create mode 100644 firmware/common/include/db_experiment.h rename firmware/targets/esp32s3/main/{fan_fixture.c => fan_characterization.c} (86%) create mode 100644 firmware/targets/esp32s3/main/fan_characterization.h delete mode 100644 firmware/targets/esp32s3/main/fan_fixture.h create mode 100644 sdkconfig.defaults.fan-characterization delete mode 100644 sdkconfig.defaults.fanfixture create mode 100644 tests/test_experiment_profiles.py rename tests/{test_fan_fixture_contract.py => test_fan_characterization_contract.py} (74%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6b3549..d30b0fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ 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 @@ -26,21 +26,21 @@ 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-fixture-refusal: - name: ESP-IDF 5.3.5 / fan fixture refuses unconfigured build + fan-characterization-refusal: + name: ESP-IDF 5.3.5 / fan-characterization refuses a build without wiring runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v6 - - name: Build fixture overlay without wiring + - name: Build the experiment profile without its wiring overlay uses: espressif/esp-idf-ci-action@v1 with: esp_idf_version: v5.3.5 target: esp32s3 path: . - command: bash ci/build-esp32s3.sh tinys3d fanfixture-unconfigured + command: bash ci/build-esp32s3.sh tinys3d fan-characterization --expect-refusal python-host: name: Python host contracts @@ -77,16 +77,16 @@ jobs: test "$status" -eq 2 native-c: - name: Native C state machine (${{ matrix.profile }}) + name: Native C state machine (${{ matrix.experiment }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - - profile: normal + - experiment: baseline defines: "" - - profile: fan-fixture - defines: "-DCONFIG_DB_FAN_FIXTURE=1" + - experiment: fan-characterization + defines: "-DCONFIG_DB_EXPERIMENT_FAN_CHARACTERIZATION=1" steps: - name: Check out repository uses: actions/checkout@v6 @@ -99,7 +99,7 @@ jobs: 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-fixture tests + - name: Execute state-machine and fan-characterization tests run: | ./test_db_run ./test_db_fan diff --git a/.gitignore b/.gitignore index aaace40..9dca139 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,6 @@ __pycache__/ .venv/ .pytest_cache/ artifacts/ -sdkconfig.fanfixture.local +sdkconfig.fan-characterization.local test_db_fan test_db_fan.exe diff --git a/ci/build-esp32s3.sh b/ci/build-esp32s3.sh index f612da4..3d06f61 100644 --- a/ci/build-esp32s3.sh +++ b/ci/build-esp32s3.sh @@ -1,15 +1,19 @@ #!/usr/bin/env bash set -euo pipefail -# Usage: ci/build-esp32s3.sh [n8r8|tinys3d] [fanfixture|fanfixture-unconfigured] +# Usage: ci/build-esp32s3.sh [experiment] [--expect-refusal] +# board: n8r8 | tinys3d +# experiment: baseline (default) | fan-characterization # -# Without a second argument this is a normal profile, which must contain no -# fan-stimulus code. "fanfixture" layers the bench fan-fixture overlay plus the -# untracked sdkconfig.fanfixture.local that states the wired GPIOs. -# "fanfixture-unconfigured" proves that the overlay alone refuses to build. -profile="${1:-n8r8}" -fixture="${2:-}" -case "$profile" in +# 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") @@ -19,45 +23,59 @@ case "$profile" in expect=("CONFIG_SPIRAM_MODE_QUAD 1" 'CONFIG_DB_TARGET_NAME "esp32s3-tinys3d"' "CONFIG_DB_RF_SWITCH_GPIO 38") ;; *) - echo "unknown board profile: $profile (expected n8r8 or tinys3d)" >&2 + echo "unknown board profile: $board (expected n8r8 or tinys3d)" >&2 exit 2 ;; esac -case "$fixture" in - "") ;; - fanfixture) - if [[ ! -f sdkconfig.fanfixture.local ]]; then - echo "fanfixture needs sdkconfig.fanfixture.local stating the wired fixture (docs/FAN_FIXTURE.md)" >&2 - exit 2 - fi - defaults="$defaults;sdkconfig.defaults.fanfixture;sdkconfig.fanfixture.local" - expect+=("CONFIG_DB_FAN_FIXTURE 1") + +# 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") ;; - fanfixture-unconfigured) - defaults="$defaults;sdkconfig.defaults.fanfixture" + 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 fixture option: $fixture (expected fanfixture or fanfixture-unconfigured)" >&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 [[ "$fixture" == fanfixture-unconfigured ]]; then +if [[ -n "$expect_refusal" ]]; then if idf.py -D SDKCONFIG_DEFAULTS="$defaults" build > idf-build.log 2>&1; then - echo "unconfigured fan-fixture build succeeded; it must refuse to build" >&2 + echo "unconfigured $experiment build succeeded; it must refuse to build" >&2 exit 1 fi - for setting in CONFIG_DB_FAN_PWM_GATE_GPIO CONFIG_DB_FAN_GATE_SINK_LEVEL CONFIG_DB_FAN_TACH_GPIO; do - if ! grep -q "#error \"Fan fixture: set $setting" idf-build.log; then - echo "unconfigured fan-fixture build did not name $setting" >&2 + 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 "profile $profile unconfigured fan fixture refused as expected" + echo "$board/$experiment without wiring refused as expected" exit 0 fi @@ -70,7 +88,7 @@ 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 @@ -82,19 +100,23 @@ if ! grep -q ' app_main$' <<< "$symbols"; then echo "could not read symbols from build/dragonbench.elf with $nm_tool" >&2 exit 1 fi -stimulus_symbols="$(grep -E ' (ledc_|pcnt_|fan_fixture_)' <<< "$symbols" || true)" -if [[ -z "$fixture" ]]; then - if grep -q "CONFIG_DB_FAN_FIXTURE" build/config/sdkconfig.h; then - echo "normal profile $profile enabled the fan fixture" >&2 +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 "$stimulus_symbols" ]]; then - echo "normal profile $profile links fan-stimulus code:" >&2 - echo "$stimulus_symbols" >&2 + if [[ -n "$code" ]]; then + echo "$board/$experiment links $other code:" >&2 + echo "$code" >&2 exit 1 fi -elif ! grep -q ' fan_fixture_hold$' <<< "$stimulus_symbols"; then - echo "fan-fixture profile $profile does not link fan_fixture_hold" >&2 - exit 1 -fi -echo "profile $profile${fixture:+ ($fixture)} verified" +done +echo "$board/$experiment verified" diff --git a/cli/dragonbench/main.py b/cli/dragonbench/main.py index 51614fd..314965a 100644 --- a/cli/dragonbench/main.py +++ b/cli/dragonbench/main.py @@ -86,7 +86,7 @@ def parser() -> argparse.ArgumentParser: 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 on a fan-fixture build", + 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.", ) @@ -154,8 +154,9 @@ def fan_sweep(args: argparse.Namespace, client: Client, sleep=time.sleep, clock= duty_tenths(args.step_pct, "step-pct"), args.order) device = client.request("GET", "/api/v1/device") - if (device.get("bench_stimulus") or {}).get("fan_pwm_fixture") is not True: - raise ClientError("device is not a fan-fixture build; FAN_PWM_HOLD is unavailable") + 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", diff --git a/docs/FAN_FIXTURE.md b/docs/FAN_CHARACTERIZATION.md similarity index 100% rename from docs/FAN_FIXTURE.md rename to docs/FAN_CHARACTERIZATION.md diff --git a/firmware/common/db_run.c b/firmware/common/db_run.c index 0d6d1c7..c96783a 100644 --- a/firmware/common/db_run.c +++ b/firmware/common/db_run.c @@ -28,13 +28,13 @@ 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_FAN_FIXTURE_BUILD; + 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 a fan-fixture build"); + snprintf(error, error_len, "FAN_PWM_HOLD requires the fan-characterization experiment profile"); return false; } if (!request || !db_workload_supported(request->workload)) { 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 index c9c27e3..be7aebe 100644 --- a/firmware/common/include/db_fan.h +++ b/firmware/common/include/db_fan.h @@ -7,23 +7,14 @@ // // 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_fixture.c, behind db_fan_ops_t. +// 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 -#if defined(ESP_PLATFORM) -#include "sdkconfig.h" -#endif - -// Normal profiles leave CONFIG_DB_FAN_FIXTURE unset; only the fan-fixture -// overlay (sdkconfig.defaults.fanfixture) turns the stimulus on. -#if defined(CONFIG_DB_FAN_FIXTURE) && CONFIG_DB_FAN_FIXTURE -#define DB_FAN_FIXTURE_BUILD 1 -#else -#define DB_FAN_FIXTURE_BUILD 0 -#endif +#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 diff --git a/firmware/common/include/db_run.h b/firmware/common/include/db_run.h index 7bdcf37..333ed61 100644 --- a/firmware/common/include/db_run.h +++ b/firmware/common/include/db_run.h @@ -21,7 +21,7 @@ typedef enum { DB_NVS_WRITE, DB_OTA_PARTITION_WRITE, DB_CONTROLLED_REBOOT, - DB_FAN_PWM_HOLD, // bench fan-fixture stimulus; supported only in fixture builds + DB_FAN_PWM_HOLD, // bench stimulus; supported only in the fan-characterization profile DB_WORKLOAD_COUNT } db_workload_t; diff --git a/firmware/targets/esp32s3/main/CMakeLists.txt b/firmware/targets/esp32s3/main/CMakeLists.txt index 1e666ad..1d77359 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" "fan_fixture.c" + SRCS "main.c" "fan_characterization.c" INCLUDE_DIRS "." REQUIRES common diff --git a/firmware/targets/esp32s3/main/Kconfig.projbuild b/firmware/targets/esp32s3/main/Kconfig.projbuild index 6afd2c5..271af82 100644 --- a/firmware/targets/esp32s3/main/Kconfig.projbuild +++ b/firmware/targets/esp32s3/main/Kconfig.projbuild @@ -31,19 +31,38 @@ config DB_RF_SWITCH_GPIO Driven low at boot to select the onboard antenna. TinyS3[D]: GPIO38 (high selects the external U.FL connector). -config DB_FAN_FIXTURE - bool "Bench fan-characterization fixture (NOT product fan control)" - default n +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. Normal profiles leave this off; - enable it only with sdkconfig.defaults.fanfixture plus a local - overlay that states the wired GPIOs. See docs/FAN_FIXTURE.md. + 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_FAN_FIXTURE +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 @@ -90,5 +109,6 @@ config DB_FAN_TACH_PPR_EVIDENCE 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/fan_fixture.c b/firmware/targets/esp32s3/main/fan_characterization.c similarity index 86% rename from firmware/targets/esp32s3/main/fan_fixture.c rename to firmware/targets/esp32s3/main/fan_characterization.c index 5731dfa..092469a 100644 --- a/firmware/targets/esp32s3/main/fan_fixture.c +++ b/firmware/targets/esp32s3/main/fan_characterization.c @@ -1,6 +1,6 @@ -#include "fan_fixture.h" +#include "fan_characterization.h" -#if DB_FAN_FIXTURE_BUILD +#if DB_EXPERIMENT_FAN_CHARACTERIZATION #include "driver/gpio.h" #include "driver/ledc.h" @@ -14,13 +14,13 @@ // 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 fixture: set CONFIG_DB_FAN_PWM_GATE_GPIO to the GPIO wired to the external open-drain stage gate (docs/FAN_FIXTURE.md)" +#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 fixture: 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_FIXTURE.md)" +#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 fixture: set CONFIG_DB_FAN_TACH_GPIO to the GPIO wired to the conditioned tach signal (docs/FAN_FIXTURE.md)" +#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 @@ -30,20 +30,20 @@ #define FAN_OCTAL_PSRAM 0 #endif #if DB_FAN_PIN_RESERVED(CONFIG_DB_FAN_PWM_GATE_GPIO, FAN_OCTAL_PSRAM) -#error "Fan fixture: CONFIG_DB_FAN_PWM_GATE_GPIO is a strapping, USB, flash/PSRAM, console, or nonexistent pin" +#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 fixture: CONFIG_DB_FAN_TACH_GPIO is a strapping, USB, flash/PSRAM, console, or nonexistent pin" +#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 fixture: gate and tach must be different GPIOs" +#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 fixture: GPIO collides with CONFIG_DB_RF_SWITCH_GPIO" +#error "fan-characterization: GPIO collides with CONFIG_DB_RF_SWITCH_GPIO" #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 fixture: CONFIG_DB_FAN_TACH_PPR needs CONFIG_DB_FAN_TACH_PPR_EVIDENCE"); + "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 @@ -51,7 +51,7 @@ _Static_assert(CONFIG_DB_FAN_TACH_PPR == 0 || sizeof(CONFIG_DB_FAN_TACH_PPR_EVID #define TACH_INTERNAL_PULLUP 0 #endif -#define TAG "fan_fixture" +#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 @@ -217,16 +217,16 @@ static bool tach_init(void) { static void shutdown_release(void) { db_fan_release(&fixture); } -void fan_fixture_boot(void) { +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, "BENCH FAN FIXTURE BUILD: gate GPIO%d (sink level %d), tach GPIO%d, line %s", + 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_fixture_hold(const db_run_request_t *request, bool (*should_abort)(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) { abort_requested = should_abort; const db_fan_hold_outcome_t outcome = @@ -235,9 +235,9 @@ db_fan_hold_outcome_t fan_fixture_hold(const db_run_request_t *request, bool (*s return outcome; } -uint32_t fan_fixture_ppr(void) { return CONFIG_DB_FAN_TACH_PPR; } +uint32_t fan_characterization_ppr(void) { return CONFIG_DB_FAN_TACH_PPR; } -void fan_fixture_describe(cJSON *parent) { +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"); diff --git a/firmware/targets/esp32s3/main/fan_characterization.h b/firmware/targets/esp32s3/main/fan_characterization.h new file mode 100644 index 0000000..6c926a0 --- /dev/null +++ b/firmware/targets/esp32s3/main/fan_characterization.h @@ -0,0 +1,25 @@ +#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); + +// 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/fan_fixture.h b/firmware/targets/esp32s3/main/fan_fixture.h deleted file mode 100644 index b469473..0000000 --- a/firmware/targets/esp32s3/main/fan_fixture.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -// ESP32-S3 backend for the bench-only fan-characterization fixture. Compiled -// only when CONFIG_DB_FAN_FIXTURE is set; normal profiles contain none of it. - -#include "db_fan.h" -#include "db_run.h" - -#if DB_FAN_FIXTURE_BUILD -#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_fixture_boot(void); - -db_fan_hold_outcome_t fan_fixture_hold(const db_run_request_t *request, bool (*should_abort)(void), - db_fan_hold_result_t *result); - -// Configured pulses per revolution; 0 means unknown and RPM is never derived. -uint32_t fan_fixture_ppr(void); - -// Adds the static fixture configuration and live line state to a device object. -void fan_fixture_describe(cJSON *parent); -#endif diff --git a/firmware/targets/esp32s3/main/main.c b/firmware/targets/esp32s3/main/main.c index 489ad5c..c8e61e9 100644 --- a/firmware/targets/esp32s3/main/main.c +++ b/firmware/targets/esp32s3/main/main.c @@ -7,10 +7,11 @@ #include #include "cJSON.h" +#include "db_experiment.h" #include "db_fan.h" #include "db_network.h" #include "db_run.h" -#include "fan_fixture.h" +#include "fan_characterization.h" #include "driver/gpio.h" #include "driver/temperature_sensor.h" #include "esp_attr.h" @@ -327,13 +328,13 @@ static void workload_task(void *unused) { } case DB_CONTROLLED_REBOOT: ok = wait_abortable(run.request.duration_ms); break; -#if DB_FAN_FIXTURE_BUILD +#if DB_EXPERIMENT_FAN_CHARACTERIZATION case DB_FAN_PWM_HOLD: { db_fan_hold_result_t hold; - ok = fan_fixture_hold(&run.request, should_abort, &hold) == DB_FAN_HOLD_PASS; + 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_fixture_ppr())) + 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; @@ -376,13 +377,14 @@ static cJSON *identity_json(void) { cJSON_AddStringToObject(o, "firmware_version", CONFIG_DB_FIRMWARE_VERSION); cJSON_AddStringToObject(o, "device_id", device_id); cJSON_AddStringToObject(o, "image_class", "characterization"); + cJSON_AddStringToObject(o, "experiment_profile", DB_EXPERIMENT_PROFILE_NAME); // Product actuator capabilities are absent in every build, fixture included. cJSON_AddBoolToObject(o, "heater_capability", false); cJSON_AddBoolToObject(o, "fan_control_capability", false); cJSON *bench = cJSON_AddObjectToObject(o, "bench_stimulus"); - cJSON_AddBoolToObject(bench, "fan_pwm_fixture", DB_FAN_FIXTURE_BUILD); -#if DB_FAN_FIXTURE_BUILD - fan_fixture_describe(bench); + cJSON_AddBoolToObject(bench, "fan_pwm_fixture", DB_EXPERIMENT_FAN_CHARACTERIZATION); +#if DB_EXPERIMENT_FAN_CHARACTERIZATION + fan_characterization_describe(bench); #endif cJSON_AddStringToObject(o, "measurement_authority", "external_bench_equipment"); return o; @@ -457,7 +459,7 @@ static esp_err_t workloads_get(httpd_req_t *req) { 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-fixture build"); + if (!db_workload_supported(DB_FAN_PWM_HOLD)) cJSON_AddStringToObject(o, "reason", "requires fan-characterization experiment profile"); } cJSON_AddItemToArray(a, o); } @@ -712,7 +714,7 @@ static const char landing[] = "" "
" "

DragonBench characterization image

" -"

No product actuator support. This image has no heater or product fan-control path. A bench fan-fixture build 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.

" +"

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.

" "
" "
" "
" @@ -1224,8 +1226,8 @@ static void antenna_init(void) { } void app_main(void) { -#if DB_FAN_FIXTURE_BUILD - fan_fixture_boot(); // release the stimulus line before anything else +#if DB_EXPERIMENT_FAN_CHARACTERIZATION + fan_characterization_boot(); // release the stimulus line before anything else #endif ESP_ERROR_CHECK(nvs_flash_init()); state_lock = xSemaphoreCreateMutex(); diff --git a/sdkconfig.defaults b/sdkconfig.defaults index d8f36db..3d43c86 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -17,3 +17,6 @@ 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 +# 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.fanfixture b/sdkconfig.defaults.fanfixture deleted file mode 100644 index 853f935..0000000 --- a/sdkconfig.defaults.fanfixture +++ /dev/null @@ -1,7 +0,0 @@ -# Bench-only fan-characterization fixture. NOT product fan control. -# Layer after a board profile, then add a local, untracked overlay that states -# the wired fixture. Every fixture GPIO defaults to -1, so a build without that -# overlay fails with an #error naming the missing setting. Example: -# idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.tinys3d;sdkconfig.defaults.fanfixture;sdkconfig.fanfixture.local" build -# See docs/FAN_FIXTURE.md for what sdkconfig.fanfixture.local must contain. -CONFIG_DB_FAN_FIXTURE=y diff --git a/tests/native/README.md b/tests/native/README.md index ea35282..b6af4fc 100644 --- a/tests/native/README.md +++ b/tests/native/README.md @@ -6,12 +6,12 @@ validation, state transitions, run identity retention, and abort behavior. planning, 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 a normal profile and with -`-DCONFIG_DB_FAN_FIXTURE=1`. +`-DCONFIG_DB_EXPERIMENT_FAN_CHARACTERIZATION=1`. From the repository root on a host with a C11 compiler: ```text -for defines in "" "-DCONFIG_DB_FAN_FIXTURE=1"; do +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 \ diff --git a/tests/native/test_db_fan.c b/tests/native/test_db_fan.c index e998b2b..7f9644d 100644 --- a/tests/native/test_db_fan.c +++ b/tests/native/test_db_fan.c @@ -1,5 +1,5 @@ -// Fan-fixture common logic. Built twice: as a normal profile, and with -// -DCONFIG_DB_FAN_FIXTURE=1 as the bench fixture profile. +// 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" @@ -136,14 +136,14 @@ 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_FAN_FIXTURE_BUILD == 1)); + 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_FAN_FIXTURE_BUILD +#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 a fan-fixture build") == 0); - // Every other workload stays available in a normal profile. + 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 @@ -159,7 +159,7 @@ static void test_capability_gate(void) { } static void test_parameter_bounds(void) { -#if DB_FAN_FIXTURE_BUILD +#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))); @@ -474,6 +474,6 @@ int main(void) { } test_release_on_every_path(); test_phase_end_fits_event_slot(); - printf("db_fan tests passed (%s profile)\n", DB_FAN_FIXTURE_BUILD ? "fan-fixture" : "normal"); + printf("db_fan tests passed (%s experiment profile)\n", DB_EXPERIMENT_PROFILE_NAME); return 0; } diff --git a/tests/test_experiment_profiles.py b/tests/test_experiment_profiles.py new file mode 100644 index 0000000..499346d --- /dev/null +++ b/tests/test_experiment_profiles.py @@ -0,0 +1,70 @@ +"""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"} + + +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_baseline_is_explicit_and_boards_do_not_choose_experiments(self): + self.assertIn("CONFIG_DB_EXPERIMENT_BASELINE=y", (ROOT / "sdkconfig.defaults").read_text()) + self.assertNotIn("DB_EXPERIMENT", (ROOT / "sdkconfig.defaults.tinys3d").read_text()) + + def test_each_experiment_has_exactly_one_overlay_selecting_only_itself(self): + overlays = sorted(p.name for p in ROOT.glob("sdkconfig.defaults.*") if p.name != "sdkconfig.defaults.tinys3d") + experiments = sorted(n for n in EXPECTED.values() if n != "baseline") + self.assertEqual(overlays, [f"sdkconfig.defaults.{n}" for n in experiments]) + 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) + + 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_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_fixture_contract.py b/tests/test_fan_characterization_contract.py similarity index 74% rename from tests/test_fan_fixture_contract.py rename to tests/test_fan_characterization_contract.py index 34412bf..55cf272 100644 --- a/tests/test_fan_fixture_contract.py +++ b/tests/test_fan_characterization_contract.py @@ -1,9 +1,10 @@ -"""Static contract for the bench-only fan-characterization fixture. +"""Static contract for the fan-characterization experiment profile. -The fixture is characterization equipment: an external open-drain PWM stimulus -and tach capture, compiled only into an explicit fixture profile. These checks -keep it from leaking into normal profiles, from growing product fan control, -and from carrying guessed wiring or fan constants. +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 @@ -14,7 +15,7 @@ ROOT = Path(__file__).parents[1] MAIN_C = ROOT / "firmware/targets/esp32s3/main/main.c" -FIXTURE_C = ROOT / "firmware/targets/esp32s3/main/fan_fixture.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" @@ -33,12 +34,7 @@ def _function_body(source, signature): return source[start:source.index("\n}\n", start)] -class NormalProfilesTests(unittest.TestCase): - def test_fixture_is_off_by_default_and_in_every_normal_profile(self): - self.assertEqual(_kconfig_default("DB_FAN_FIXTURE"), "n") - for profile in ("sdkconfig.defaults", "sdkconfig.defaults.tinys3d"): - self.assertNotIn("DB_FAN", _read(profile), profile) - +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") @@ -47,38 +43,48 @@ def test_fixture_wiring_has_no_defaults(self): self.assertEqual(_kconfig_default("DB_FAN_TACH_INTERNAL_PULLUP"), "n") self.assertEqual(_kconfig_default("DB_FAN_TACH_PPR_EVIDENCE"), '""') - def test_fixture_overlay_only_enables_and_assigns_no_pins(self): - overlay = _read("sdkconfig.defaults.fanfixture") + 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.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_FAN_FIXTURE=y"]) - self.assertIn("sdkconfig.fanfixture.local", _read(".gitignore")) + 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_fixture_refuses_to_build(self): + 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 fixture: set {setting}") + 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_FAN_TACH_PPR_EVIDENCE", source) - def test_fixture_code_is_compiled_out_of_normal_builds(self): + def test_profile_code_is_compiled_out_of_other_profiles(self): fixture = _read(FIXTURE_C) - self.assertLess(fixture.index("#if DB_FAN_FIXTURE_BUILD"), fixture.index("driver/ledc.h")) + self.assertLess(fixture.index("#if DB_EXPERIMENT_FAN_CHARACTERIZATION"), fixture.index("driver/ledc.h")) main = _read(MAIN_C) - for call in ("fan_fixture_boot()", "fan_fixture_hold(", "fan_fixture_describe("): + for call in ("fan_characterization_boot()", "fan_characterization_hold(", "fan_characterization_describe("): idx = main.index(call) - # Inside an open "#if DB_FAN_FIXTURE_BUILD" block. - self.assertGreater(main.rfind("#if DB_FAN_FIXTURE_BUILD", 0, idx), main.rfind("#endif", 0, idx), call) - self.assertIn("return DB_FAN_FIXTURE_BUILD;", _read("firmware/common/db_run.c")) + # 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("normal profile $profile links fan-stimulus code", script) - self.assertIn("(ledc_|pcnt_|fan_fixture_)", script) + self.assertIn('[fan-characterization]="ledc_|pcnt_|fan_characterization_"', script) + self.assertIn("links $other code", script) workflow = _read(".github/workflows/ci.yml") - self.assertIn("fanfixture-unconfigured", workflow) - self.assertIn("-DCONFIG_DB_FAN_FIXTURE=1", workflow) + 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): @@ -87,10 +93,10 @@ def test_product_actuator_capabilities_are_false_in_every_build(self): 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_FAN_FIXTURE_BUILD") + guard = identity.index("#if DB_EXPERIMENT_FAN_CHARACTERIZATION") self.assertLess(heater, guard) self.assertLess(fan, guard) - self.assertIn('"fan_pwm_fixture", DB_FAN_FIXTURE_BUILD', identity) + 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. @@ -120,7 +126,7 @@ 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_fixture_boot(); // release the stimulus line before anything else") + 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:"):] diff --git a/tests/test_fan_sweep.py b/tests/test_fan_sweep.py index f75384c..d373024 100644 --- a/tests/test_fan_sweep.py +++ b/tests/test_fan_sweep.py @@ -9,7 +9,7 @@ class FakeFanDevice: - """Minimal stand-in for a fan-fixture build's HTTP API.""" + """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 @@ -30,6 +30,7 @@ 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) diff --git a/web/index.html b/web/index.html index 781f752..509d7ff 100644 --- a/web/index.html +++ b/web/index.html @@ -83,7 +83,7 @@

DragonBench

From c0d85b2ce4c32565e4b1de93248640cd770c5381 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:07:31 +0000 Subject: [PATCH 07/16] firmware: report image identity, build provenance, and measurement sources /api/v1/device now states what produced an artifact without inference: board_profile, experiment_profile, device_id, and a build object with firmware_version, git_sha, source_tree, and esp_idf. The boot event carries the same image identity so a serial log alone names what ran. build_provenance.cmake runs on every build and rewrites db_build_provenance.h only when it changes, so incremental builds never report a stale revision. It records only what git establishes: no git, no work tree, or a failed query gives git_sha null and source_tree unknown. Untracked, non-ignored files count as dirty. idf-build.log is now ignored so CI's own log does not dirty the tree. measurement_authority: external_bench_equipment stopped being true once PCNT counted tach edges. It is replaced by measurement_provenance, which maps each quantity to dut., derived., or external. fan_speed is external unless a PPR was configured with evidence. The fan profile also lists fan_tach_edges in /api/v1/sensors, and CLI export includes /api/v1/device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XaHXL38BsvntY8t344vv45 --- .gitignore | 1 + cli/dragonbench/main.py | 1 + firmware/targets/esp32s3/main/CMakeLists.txt | 12 ++ .../esp32s3/main/build_provenance.cmake | 43 ++++++ .../esp32s3/main/fan_characterization.c | 2 + .../esp32s3/main/fan_characterization.h | 3 + firmware/targets/esp32s3/main/main.c | 60 +++++++- protocol/openapi.yaml | 100 +++++++++---- tests/test_identity_provenance.py | 132 ++++++++++++++++++ web/index.html | 5 +- 10 files changed, 324 insertions(+), 35 deletions(-) create mode 100644 firmware/targets/esp32s3/main/build_provenance.cmake create mode 100644 tests/test_identity_provenance.py diff --git a/.gitignore b/.gitignore index 9dca139..3b3cf2d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ artifacts/ sdkconfig.fan-characterization.local test_db_fan test_db_fan.exe +idf-build.log diff --git a/cli/dragonbench/main.py b/cli/dragonbench/main.py index 314965a..b6e407b 100644 --- a/cli/dragonbench/main.py +++ b/cli/dragonbench/main.py @@ -316,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/firmware/targets/esp32s3/main/CMakeLists.txt b/firmware/targets/esp32s3/main/CMakeLists.txt index 1d77359..fcba199 100644 --- a/firmware/targets/esp32s3/main/CMakeLists.txt +++ b/firmware/targets/esp32s3/main/CMakeLists.txt @@ -18,3 +18,15 @@ idf_component_register( 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/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 index 092469a..313053d 100644 --- a/firmware/targets/esp32s3/main/fan_characterization.c +++ b/firmware/targets/esp32s3/main/fan_characterization.c @@ -235,6 +235,8 @@ db_fan_hold_outcome_t fan_characterization_hold(const db_run_request_t *request, 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) { diff --git a/firmware/targets/esp32s3/main/fan_characterization.h b/firmware/targets/esp32s3/main/fan_characterization.h index 6c926a0..f37a020 100644 --- a/firmware/targets/esp32s3/main/fan_characterization.h +++ b/firmware/targets/esp32s3/main/fan_characterization.h @@ -17,6 +17,9 @@ 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); diff --git a/firmware/targets/esp32s3/main/main.c b/firmware/targets/esp32s3/main/main.c index c8e61e9..5bd9648 100644 --- a/firmware/targets/esp32s3/main/main.c +++ b/firmware/targets/esp32s3/main/main.c @@ -7,6 +7,7 @@ #include #include "cJSON.h" +#include "db_build_provenance.h" #include "db_experiment.h" #include "db_fan.h" #include "db_network.h" @@ -370,14 +371,51 @@ 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"); - cJSON_AddStringToObject(o, "experiment_profile", DB_EXPERIMENT_PROFILE_NAME); // Product actuator capabilities are absent in every build, fixture included. cJSON_AddBoolToObject(o, "heater_capability", false); cJSON_AddBoolToObject(o, "fan_control_capability", false); @@ -386,7 +424,7 @@ static cJSON *identity_json(void) { #if DB_EXPERIMENT_FAN_CHARACTERIZATION fan_characterization_describe(bench); #endif - cJSON_AddStringToObject(o, "measurement_authority", "external_bench_equipment"); + add_measurement_provenance(o); return o; } @@ -448,6 +486,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); } @@ -793,13 +836,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'}" " 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'}" -" role('identity').textContent=(data.target||'target —')+' · firmware '+(data.firmware_version||'—');" +" 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);" @@ -1245,7 +1289,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()) { diff --git a/protocol/openapi.yaml b/protocol/openapi.yaml index 05dc022..717273a 100644 --- a/protocol/openapi.yaml +++ b/protocol/openapi.yaml @@ -5,16 +5,25 @@ info: paths: /api/v1/device: get: - summary: Device identity and immutable product boundary + summary: Device identity, image provenance, and immutable product boundary description: >- - 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 a bench fan-characterization fixture build, which can - drive an external open-drain stage that sinks a fan PWM line; that - build also reports bench_stimulus.fan_fixture with its configured - gate/tach GPIOs, gate_sink_level, line_state (released, - driven, release_failed), tach_edge, and tach_ppr (null when unknown). + 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 @@ -22,7 +31,29 @@ paths: 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-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: @@ -32,11 +63,11 @@ paths: fan_pwm_fixture: {type: boolean} fan_fixture: type: object - description: Present only when fan_pwm_fixture is true + 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, driven, release_failed]} + 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} @@ -46,32 +77,45 @@ paths: /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 description: >- FAN_PWM_HOLD is listed with class bench_stimulus. It is "available" - only in a fan-fixture build and "unsupported" (reason "requires - fan-fixture build") otherwise. + 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-fixture builds only) drives the external stage gate - so the fan PWM line is sunk for sink_duty_pct of each period at - pwm_hz, for duration_ms, while PCNT counts tach falling edges. It - requires pwm_hz, sink_duty_pct, and duration_ms, and pwm_hz or - sink_duty_pct on any other workload is rejected. 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, applied_sink_duty_pct, and - 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; a failed release is reported as - "released": false with fixture_error and a fault event. + 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: 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/web/index.html b/web/index.html index 509d7ff..3aaaa52 100644 --- a/web/index.html +++ b/web/index.html @@ -169,13 +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'} 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'} - role('identity').textContent=(data.target||'target —')+' · firmware '+(data.firmware_version||'—'); + 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); From 5f672cc4aecbb876e160ea6f619188cb9434c499 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:09:22 +0000 Subject: [PATCH 08/16] docs: describe experiment profiles and the revised fan profile docs/EXPERIMENT_PROFILES.md defines the platform pattern: one board profile plus one mutually exclusive experiment profile per image, what every profile reports (identity, build revision, measurement provenance), and the rules for adding one. docs/FAN_CHARACTERIZATION.md (was FAN_FIXTURE.md) now covers the static 0 % / 100 % endpoints and why LEDC never sees 2^duty_resolution, tach readiness before actuation, the hold sequence, the stimulus field, the new fixture_error values, and identity/provenance. README, ARCHITECTURE, BENCH_WORKFLOW, and the native-test README follow the new names. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XaHXL38BsvntY8t344vv45 --- README.md | 29 ++++++---- docs/ARCHITECTURE.md | 20 ++++--- docs/BENCH_WORKFLOW.md | 11 +++- docs/EXPERIMENT_PROFILES.md | 73 ++++++++++++++++++++++++ docs/FAN_CHARACTERIZATION.md | 106 +++++++++++++++++++++++------------ tests/native/README.md | 10 ++-- 6 files changed, 188 insertions(+), 61 deletions(-) create mode 100644 docs/EXPERIMENT_PROFILES.md diff --git a/README.md b/README.md index 56c91b7..7a9ea45 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,17 @@ 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. -Normal builds contain no heater or fan GPIOs and expose no actuator API. An -explicit, off-by-default [bench fan-fixture profile](docs/FAN_FIXTURE.md) adds -a single stimulus workload, `FAN_PWM_HOLD`, that drives only the gate of an -external open-drain stage and counts tach edges for fan characterization. That -is characterization equipment, not product fan control: no build has a heater -path, a closed loop, thresholds, or fan-safety policy, and -`heater_capability` and `fan_control_capability` are `false` in every build. +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 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 @@ -89,7 +93,8 @@ 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), -[fan fixture](docs/FAN_FIXTURE.md), and [protocol contract](protocol/openapi.yaml). +[experiment profiles](docs/EXPERIMENT_PROFILES.md), [fan characterization](docs/FAN_CHARACTERIZATION.md), +and [protocol contract](protocol/openapi.yaml). ## Validation status @@ -115,10 +120,10 @@ yet been exercised on hardware, and workload execution and electrical characterization remain unvalidated there too. See [TinyS3[D] profile](docs/TARGET_TINYS3D.md). -The bench fan-fixture 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 GPIOs are not yet defined, and no part -of it has run on hardware. +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 GPIOs are not yet defined, and +no part of it has run on hardware. ## Scope rule diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fe3e23f..dc93713 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,16 +1,22 @@ # 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. `db_fan.c` holds - the bench fan-fixture stimulus lifecycle and tach arithmetic behind a small - hardware seam, so release-on-every-path is host-tested. + 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. `fan_fixture.c` (LEDC - stimulus, PCNT tach) compiles only into the explicit fan-fixture profile; see - [fan fixture](FAN_FIXTURE.md). + 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. Multi-step orchestration, such as the fan duty sweep, lives on the host; firmware runs one primitive at a time. diff --git a/docs/BENCH_WORKFLOW.md b/docs/BENCH_WORKFLOW.md index c22ba36..6fb8be5 100644 --- a/docs/BENCH_WORKFLOW.md +++ b/docs/BENCH_WORKFLOW.md @@ -16,9 +16,14 @@ or electrical pass/fail limits. `OTA_PARTITION_WRITE` erases, writes, and verifi only an inactive OTA slot; it never selects that slot for boot. Controlled reboot is single-shot and never forms a reboot loop. -On a bench fan-fixture build, `FAN_PWM_HOLD` and the host `fan-sweep` command -provide the stimulus and raw tach counts. Wiring, semantics, and limits are in -[fan fixture](FAN_FIXTURE.md). Fan supply current, PWM-line and tach waveforms, +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`, diff --git a/docs/EXPERIMENT_PROFILES.md b/docs/EXPERIMENT_PROFILES.md new file mode 100644 index 0000000..e34d669 --- /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-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 index dc28a9f..d2ffe0c 100644 --- a/docs/FAN_CHARACTERIZATION.md +++ b/docs/FAN_CHARACTERIZATION.md @@ -1,10 +1,15 @@ -# Bench fan-characterization fixture +# Fan-characterization experiment profile -> **BENCH CHARACTERIZATION EQUIPMENT — NOT PRODUCT FAN CONTROL.** This profile -> drives only the gate of an external open-drain stage and counts tach edges. +> **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; no part of the fixture has run on hardware. The wiring is still to be defined, see [Open items](#open-items). @@ -20,15 +25,16 @@ frequency, PPR, or production GPIO. | Piece | Where | |---|---| -| `CONFIG_DB_FAN_FIXTURE` profile, off in every normal profile | `Kconfig.projbuild`, `sdkconfig.defaults.fanfixture` | +| `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` | -| LEDC stimulus and PCNT tach capture | `firmware/targets/esp32s3/main/fan_fixture.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` | -Normal builds report `FAN_PWM_HOLD` as `unsupported` (reason `requires -fan-fixture build`), reject it with HTTP 400, and link no LEDC, PCNT, or -fixture code. CI checks that absence in the ELF. +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 @@ -46,10 +52,15 @@ fixture code. CI checks that absence in the ELF. - **The firmware drives only the transistor gate.** No DragonBench pin connects to a fan lead. -- **`sink_duty_pct`** is the percentage of each PWM period during which 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. +- **`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. @@ -63,14 +74,16 @@ fixture code. CI checks that absence in the ELF. | 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_fixture_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 before it programs any stimulus. If it cannot, no stimulus is applied. | +| 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. | -To release, the firmware writes the release level to the output register, then -moves the pad from LEDC back to a plain GPIO output, so the switch cannot -glitch toward sink. Verification reads back the **pad** level. It does not +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 @@ -86,8 +99,8 @@ fails the build: strapping pins (0, 3, 45, 46), USB (19, 20), flash/PSRAM (43, 44), `CONFIG_DB_RF_SWITCH_GPIO`, 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.fanfixture.local` (ignored by Git) stating the -wired fixture: +Create an untracked `sdkconfig.fan-characterization.local` (ignored by Git) +stating the wired fixture: ```text CONFIG_DB_FAN_PWM_GATE_GPIO= @@ -105,17 +118,17 @@ 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, with the board overlay first: +Build: board overlay, then experiment overlay, then local wiring: ```text -idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.tinys3d;sdkconfig.defaults.fanfixture;sdkconfig.fanfixture.local" build +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 fanfixture +bash ci/build-esp32s3.sh tinys3d fan-characterization ``` -Delete a generated `sdkconfig` before switching between fixture and normal -profiles. At boot the serial log prints a `BENCH FAN FIXTURE BUILD` warning with -the configured pins and line state. +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 @@ -126,15 +139,25 @@ POST /api/v1/runs | 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. | -| `sink_duty_pct` | 0–100 in 0.1 steps | Quantized to the LEDC resolution; the programmed value is reported. | +| `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: -release, program LEDC, clear the tach counter, start the window, hold (checking -for abort every 20 ms), read the tach count, end the window, release. Only one -run is active at a time. + +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 @@ -150,7 +173,8 @@ 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,"applied_sink_duty_pct":35.498,"duty_resolution_bits":11} +{"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`): @@ -166,7 +190,19 @@ is applied, and `phase_end` after the line is released. 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`, - `apply_failed`, `tach_clear_failed`, `tach_read_failed`, or `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 @@ -183,7 +219,7 @@ python -m cli.dragonbench --host fan-sweep --pwm-hz \ `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 - `bench_stimulus.fan_pwm_fixture: true`, before starting any run. + `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. @@ -214,8 +250,8 @@ single hold. 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. -- LEDC output, PCNT accumulation, pad readback, and the shutdown handler have - been compiled but never run on hardware. +- Static gate drive, LEDC output, PCNT accumulation, pad readback, and the + shutdown handler have been compiled but never run on hardware. - Nothing here measures supply current, temperature, airflow, noise, or vibration (plan §11–13). Those stay with external instruments. diff --git a/tests/native/README.md b/tests/native/README.md index b6af4fc..381f69b 100644 --- a/tests/native/README.md +++ b/tests/native/README.md @@ -2,10 +2,12 @@ `test_db_run.c` directly exercises the platform-neutral C workload registry, validation, state transitions, run identity retention, and abort behavior. -`test_db_fan.c` covers the bench fan fixture: parameter bounds, LEDC duty -planning, 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 a normal profile and with +`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: From 72012f161144494a9739f0d371325ab5bad23e97 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:39:54 +0000 Subject: [PATCH 09/16] ci: compile configured fan-characterization images on hosted runners Hosted CI built only baseline images and the fan profile's expected refusal, so the real fan-characterization backend (static gate drive, LEDC, PCNT) was never compiled by GitHub Actions. A new matrix job builds it for tinys3d and n8r8 with the same ci/build-esp32s3.sh checks: zero warnings, and the fan code must link. Each entry writes sdkconfig.fan-characterization.local inside the runner workspace only (the file stays git-ignored). The GPIO values are CI compile fixtures: not DragonBench reference wiring, not JumpJet wiring, and not hardware recommendations. Each set passes the existing reserved/conflict guards for its board. The boards use opposite gate sink levels so both stage polarities compile, and PPR stays unknown. No guard is relaxed. test_experiment_profiles now requires every non-baseline experiment to have at least one configured hosted ESP-IDF build that writes its local wiring overlay, so a future profile cannot ship with refusal-only CI. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XaHXL38BsvntY8t344vv45 --- .github/workflows/ci.yml | 43 +++++++++++++++++++++++++++++++ tests/test_experiment_profiles.py | 13 ++++++++++ 2 files changed, 56 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d30b0fa..378f5b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,49 @@ jobs: path: . 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, N8R8 Octal PSRAM 33-37, strapping, USB, flash, console). + # The two boards use opposite gate sink levels so both stage + # polarities compile. 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 + 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 < sdkconfig.{name}.local", job, name) + self.assertIn("CI COMPILE FIXTURE", job, name) + def test_code_selects_experiments_only_through_db_experiment_h(self): for path in (ROOT / "firmware").rglob("*.[ch]"): if path == EXPERIMENT_H: From a2a05ca47d16c3b20425f9b44a0ca471751b2a9a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 06:11:39 +0000 Subject: [PATCH 10/16] firmware: add ESP32-S3 N16R8 board profile Adds the n16r8 board profile (ESP32-S3-WROOM-1 marked MCN16R8, 16 MB flash, 8 MB Octal PSRAM, Lonely Binary carrier) as sdkconfig.defaults.n16r8 layered on the shared defaults. It reports target esp32s3-n16r8, sets 16 MB flash, and inherits Octal PSRAM at 80 MHz and no RF switch. partitions.csv is unchanged and ends below 8 MB; the upper flash is intentionally unused. CONFIG_DB_BOARD_RESERVED_GPIO names a GPIO the board wires to an onboard peripheral. The N16R8 sets it to 48 (onboard RGB LED), and the fan-characterization pin guards refuse it as gate or tach. Other boards leave it at -1, so GPIO48 is not reserved globally. Board overlays are now identified explicitly from the build script's board profiles instead of by filename exception, so an overlay is either a board or an experiment and board overlays never select an experiment. Host tests run the real fixture pin guards through the C preprocessor for each board. CI builds n16r8 in the baseline and configured fan-characterization matrices (compile fixture GPIO6 gate, sink level 1, GPIO7 tach, PPR 0) and now also verifies each board's flash size and board-reserved GPIO. Build-validated only; physical flash and boot on the N16R8 are pending. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqMRE3TFJJAGiE368AG91j --- .github/workflows/ci.yml | 18 +- README.md | 27 ++- ci/build-esp32s3.sh | 15 +- docs/EXPERIMENT_PROFILES.md | 2 +- docs/FAN_CHARACTERIZATION.md | 2 +- docs/TARGET_ESP32S3.md | 9 +- docs/TARGET_N16R8.md | 77 ++++++++ .../targets/esp32s3/main/Kconfig.projbuild | 9 + .../esp32s3/main/fan_characterization.c | 3 + protocol/event.schema.json | 2 +- protocol/openapi.yaml | 2 +- sdkconfig.defaults.n16r8 | 13 ++ tests/test_board_profiles.py | 178 ++++++++++++++++++ tests/test_experiment_profiles.py | 53 +++++- tests/test_fan_characterization_contract.py | 2 +- 15 files changed, 385 insertions(+), 27 deletions(-) create mode 100644 docs/TARGET_N16R8.md create mode 100644 sdkconfig.defaults.n16r8 create mode 100644 tests/test_board_profiles.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 378f5b1..03f9e33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - profile: [n8r8, tinys3d] + profile: [n8r8, n16r8, tinys3d] steps: - name: Check out repository uses: actions/checkout@v6 @@ -39,9 +39,13 @@ jobs: # 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, N8R8 Octal PSRAM 33-37, strapping, USB, flash, console). - # The two boards use opposite gate sink levels so both stage - # polarities compile. PPR stays unknown. + # 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 @@ -51,6 +55,10 @@ jobs: 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 @@ -100,8 +108,10 @@ jobs: - name: Run tests env: DRAGONBENCH_REQUIRE_NODE: "1" + DRAGONBENCH_REQUIRE_CC: "1" run: | node --version + cc --version python -m unittest discover -s tests -v - name: Run reset characterization tooling tests run: python -m unittest tools.tests.test_parsing -v diff --git a/README.md b/README.md index 7a9ea45..636deec 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,14 @@ thresholds, or fan-safety policy, and `heater_capability` and the board profile, experiment profile, build revision, device identity, and where each measured quantity comes from. -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. +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). +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 @@ -52,6 +54,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 @@ -120,6 +131,10 @@ yet been exercised on hardware, and workload execution and electrical characterization remain unvalidated there too. See [TinyS3[D] profile](docs/TARGET_TINYS3D.md). +The N16R8 profile is build-validated only: physical flash and boot validation +is pending, and nothing has run on that board yet. 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 GPIOs are not yet defined, and diff --git a/ci/build-esp32s3.sh b/ci/build-esp32s3.sh index 3d06f61..d63da8c 100644 --- a/ci/build-esp32s3.sh +++ b/ci/build-esp32s3.sh @@ -2,7 +2,7 @@ set -euo pipefail # Usage: ci/build-esp32s3.sh [experiment] [--expect-refusal] -# board: n8r8 | tinys3d +# board: n8r8 | n16r8 | tinys3d # experiment: baseline (default) | fan-characterization # # An image is one board profile plus one experiment profile. Each experiment @@ -16,14 +16,21 @@ 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_BOARD_RESERVED_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_BOARD_RESERVED_GPIO 48") ;; 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_BOARD_RESERVED_GPIO -1") ;; *) - echo "unknown board profile: $board (expected n8r8 or tinys3d)" >&2 + echo "unknown board profile: $board (expected n8r8, n16r8, or tinys3d)" >&2 exit 2 ;; esac diff --git a/docs/EXPERIMENT_PROFILES.md b/docs/EXPERIMENT_PROFILES.md index e34d669..7a1d938 100644 --- a/docs/EXPERIMENT_PROFILES.md +++ b/docs/EXPERIMENT_PROFILES.md @@ -4,7 +4,7 @@ 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-tinys3d`), plus + (`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). diff --git a/docs/FAN_CHARACTERIZATION.md b/docs/FAN_CHARACTERIZATION.md index d2ffe0c..be83ccd 100644 --- a/docs/FAN_CHARACTERIZATION.md +++ b/docs/FAN_CHARACTERIZATION.md @@ -259,7 +259,7 @@ single hold. These must come from the bench before any wiring or first power: -1. Board: TinyS3[D] or N8R8 module for the fixture. +1. Board: TinyS3[D], N8R8, or N16R8 module for the fixture. 2. Gate GPIO and tach GPIO, chosen from free, non-reserved pins on that board. Check each pin's reset-state pull in the ESP32-S3 datasheet IO MUX table against the gate resistor. diff --git a/docs/TARGET_ESP32S3.md b/docs/TARGET_ESP32S3.md index af47336..13f7b79 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 diff --git a/docs/TARGET_N16R8.md b/docs/TARGET_N16R8.md new file mode 100644 index 0000000..da947a3 --- /dev/null +++ b/docs/TARGET_N16R8.md @@ -0,0 +1,77 @@ +# ESP32-S3 N16R8 target profile + +> **Validation state: build-validated only. Physical flash/boot validation is +> pending.** No image has been flashed to or booted on this board yet. + +Board facts below come from visual inspection of the physical board and its +silkscreen. Nothing here has been measured or exercised on the board. + +- 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 +- Onboard RGB LED: GPIO48 (silkscreen `RGB@IO48`) +- 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 +- RF switch: none +- 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, and no RF-switch GPIO. It changes +only three things: + +- `CONFIG_ESPTOOLPY_FLASHSIZE_16MB` +- `CONFIG_DB_TARGET_NAME="esp32s3-n16r8"` +- `CONFIG_DB_BOARD_RESERVED_GPIO=48`, so fixture wiring cannot claim the + onboard RGB LED pin. DragonBench never drives GPIO48. Other board profiles + do not reserve it. + +```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 (onboard 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. + +## Validation status + +- ESP-IDF 5.3.5 builds: `baseline`, and `fan-characterization` with the + wiring above. Build-validated only. +- Physical flash and boot: pending. +- Not validated: PSRAM detection and memory test at boot, reset and + auto-download behavior through either USB-C connector, which USB-C connector + reaches the USB-UART bridge and which reaches native USB, Wi-Fi, mDNS, + workload execution, fan characterization, and electrical characterization. diff --git a/firmware/targets/esp32s3/main/Kconfig.projbuild b/firmware/targets/esp32s3/main/Kconfig.projbuild index 271af82..7f36e98 100644 --- a/firmware/targets/esp32s3/main/Kconfig.projbuild +++ b/firmware/targets/esp32s3/main/Kconfig.projbuild @@ -30,6 +30,15 @@ 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_BOARD_RESERVED_GPIO + int "GPIO wired to an onboard peripheral (-1 if none)" + range -1 48 + default -1 + help + A GPIO the board itself wires to something, so experiment fixture + wiring must not claim it. DragonBench does not drive it. + Set per board by the sdkconfig.defaults. overlay. + ESP32-S3 N16R8 (Lonely Binary carrier): GPIO48, onboard RGB LED. choice DB_EXPERIMENT_PROFILE prompt "Experiment profile" diff --git a/firmware/targets/esp32s3/main/fan_characterization.c b/firmware/targets/esp32s3/main/fan_characterization.c index 313053d..659d047 100644 --- a/firmware/targets/esp32s3/main/fan_characterization.c +++ b/firmware/targets/esp32s3/main/fan_characterization.c @@ -41,6 +41,9 @@ #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 +#if CONFIG_DB_FAN_PWM_GATE_GPIO == CONFIG_DB_BOARD_RESERVED_GPIO || CONFIG_DB_FAN_TACH_GPIO == CONFIG_DB_BOARD_RESERVED_GPIO +#error "fan-characterization: GPIO collides with CONFIG_DB_BOARD_RESERVED_GPIO, which this board wires to an onboard peripheral" +#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"); diff --git a/protocol/event.schema.json b/protocol/event.schema.json index d9dd2ae..870283e 100644 --- a/protocol/event.schema.json +++ b/protocol/event.schema.json @@ -14,7 +14,7 @@ "parameters": {"type": "object"}, "metrics": {"type": "object"}, "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-tinys3d"]}, + "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 717273a..27598b1 100644 --- a/protocol/openapi.yaml +++ b/protocol/openapi.yaml @@ -34,7 +34,7 @@ paths: required: [board_profile, experiment_profile, firmware_version, build, device_id, measurement_provenance] properties: target: {type: string} - board_profile: {type: string, enum: [esp32s3-n8r8, esp32s3-tinys3d]} + board_profile: {type: string, enum: [esp32s3-n8r8, esp32s3-n16r8, esp32s3-tinys3d]} experiment_profile: {type: string, enum: [baseline, fan-characterization]} firmware_version: {type: string} build: diff --git a/sdkconfig.defaults.n16r8 b/sdkconfig.defaults.n16r8 new file mode 100644 index 0000000..7461c05 --- /dev/null +++ b/sdkconfig.defaults.n16r8 @@ -0,0 +1,13 @@ +# 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 and no +# RF-switch GPIO come from sdkconfig.defaults; this board profile changes only +# the flash size, the reported identity, and the onboard RGB LED reservation. +# 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 +# GPIO48 drives the carrier's onboard RGB LED (silkscreen RGB@IO48). +CONFIG_DB_BOARD_RESERVED_GPIO=48 diff --git a/tests/test_board_profiles.py b/tests/test_board_profiles.py new file mode 100644 index 0000000..fec17f6 --- /dev/null +++ b/tests/test_board_profiles.py @@ -0,0 +1,178 @@ +"""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, board-reserved GPIO) + "n8r8": ("esp32s3-n8r8", "8MB", "OCT", -1, -1), + "n16r8": ("esp32s3-n16r8", "16MB", "OCT", -1, 48), + "tinys3d": ("esp32s3-tinys3d", "8MB", "QUAD", 38, -1), +} + + +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 ("DB_RF_SWITCH_GPIO", "DB_BOARD_RESERVED_GPIO"): + 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, reserved) 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_BOARD_RESERVED_GPIO"]), reserved) + + 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}", + f"-DCONFIG_DB_RF_SWITCH_GPIO={settings['CONFIG_DB_RF_SWITCH_GPIO']}", + f"-DCONFIG_DB_BOARD_RESERVED_GPIO={settings['CONFIG_DB_BOARD_RESERVED_GPIO']}"] + 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_n16r8_rejects_the_onboard_rgb_led_gpio(self): + self.assertRejected("n16r8", 48, 7, "CONFIG_DB_BOARD_RESERVED_GPIO") + self.assertRejected("n16r8", 6, 48, "CONFIG_DB_BOARD_RESERVED_GPIO") + + def test_gpio48_is_reserved_only_on_the_board_that_wires_it(self): + self.assertAccepted("n8r8", 48, 7) + self.assertAccepted("tinys3d", 48, 5) + + 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 index f24d7a5..a5afd6c 100644 --- a/tests/test_experiment_profiles.py +++ b/tests/test_experiment_profiles.py @@ -14,6 +14,24 @@ 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(): @@ -39,20 +57,36 @@ def test_every_profile_has_one_name(self): 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()) - self.assertNotIn("DB_EXPERIMENT", (ROOT / "sdkconfig.defaults.tinys3d").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): - overlays = sorted(p.name for p in ROOT.glob("sdkconfig.defaults.*") if p.name != "sdkconfig.defaults.tinys3d") - experiments = sorted(n for n in EXPECTED.values() if n != "baseline") - self.assertEqual(overlays, [f"sdkconfig.defaults.{n}" for n in experiments]) + 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() @@ -71,6 +105,17 @@ def test_every_experiment_has_a_configured_hosted_build(self): 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]"): diff --git a/tests/test_fan_characterization_contract.py b/tests/test_fan_characterization_contract.py index 55cf272..14cb951 100644 --- a/tests/test_fan_characterization_contract.py +++ b/tests/test_fan_characterization_contract.py @@ -48,7 +48,7 @@ def test_fixture_wiring_exists_only_inside_the_profile(self): 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.tinys3d"): + 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): From 273cb4562793f9ec70c29d8259fcdbbc3df146d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:02:53 +0000 Subject: [PATCH 11/16] firmware: add board-owned DragonBench status RGB Replaces the one-off DB_BOARD_RESERVED_GPIO with board-owned status RGB resources: CONFIG_DB_STATUS_RGB_GPIO, CONFIG_DB_STATUS_RGB_POWER_GPIO and CONFIG_DB_STATUS_RGB_POWER_ACTIVE_LEVEL, set per board from its schematic or silkscreen: - N8R8 (YD-ESP32-S3 V1.4): WS2812B data GPIO48, 5 V supply, no power GPIO - N16R8 (Lonely Binary, RGB@IO48): data GPIO48, no power GPIO - TinyS3[D] (Rev D-P1): WS2812B data GPIO18, VDD from GPIO17 (high on) The fan-characterization pin guards reject the data and power pins as gate or tach, alongside the existing RF-switch and other exclusions. DragonBench drives the LED itself as a status light through the RMT bytes encoder: off from boot until the ready event, solid dim green while ready and idle, and off, under state_lock, before a run's phase_start until that run ends. It returns to green only after a passing run. The policy is a pure function, db_status_ready(), shared by every experiment profile and tested natively; on TinyS3[D] off also removes LED power. Build- and test-validated only; no LED has been observed on hardware. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqMRE3TFJJAGiE368AG91j --- README.md | 8 +- ci/build-esp32s3.sh | 7 +- docs/BENCH_WORKFLOW.md | 7 + docs/FAN_CHARACTERIZATION.md | 7 +- docs/TARGET_ESP32S3.md | 29 ++++ docs/TARGET_N16R8.md | 48 ++++-- docs/TARGET_TINYS3D.md | 32 +++- firmware/common/db_run.c | 7 + firmware/common/include/db_run.h | 4 + firmware/targets/esp32s3/main/CMakeLists.txt | 3 +- .../targets/esp32s3/main/Kconfig.projbuild | 24 ++- .../esp32s3/main/fan_characterization.c | 8 +- firmware/targets/esp32s3/main/main.c | 24 ++- firmware/targets/esp32s3/main/status_rgb.c | 101 +++++++++++++ firmware/targets/esp32s3/main/status_rgb.h | 12 ++ sdkconfig.defaults | 3 + sdkconfig.defaults.n16r8 | 9 +- sdkconfig.defaults.tinys3d | 5 + tests/native/README.md | 3 +- tests/native/test_db_run.c | 26 ++++ tests/test_board_profiles.py | 44 ++++-- tests/test_fan_characterization_contract.py | 2 + tests/test_status_rgb.py | 137 ++++++++++++++++++ 23 files changed, 498 insertions(+), 52 deletions(-) create mode 100644 firmware/targets/esp32s3/main/status_rgb.c create mode 100644 firmware/targets/esp32s3/main/status_rgb.h create mode 100644 tests/test_status_rgb.py diff --git a/README.md b/README.md index 636deec..7a4560e 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,11 @@ 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). -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. +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 diff --git a/ci/build-esp32s3.sh b/ci/build-esp32s3.sh index d63da8c..409ad42 100644 --- a/ci/build-esp32s3.sh +++ b/ci/build-esp32s3.sh @@ -17,17 +17,18 @@ case "$board" in n8r8) defaults="sdkconfig.defaults" 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_BOARD_RESERVED_GPIO -1") + "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_BOARD_RESERVED_GPIO 48") + "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_ESPTOOLPY_FLASHSIZE_8MB 1" 'CONFIG_DB_TARGET_NAME "esp32s3-tinys3d"' - "CONFIG_DB_RF_SWITCH_GPIO 38" "CONFIG_DB_BOARD_RESERVED_GPIO -1") + "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 diff --git a/docs/BENCH_WORKFLOW.md b/docs/BENCH_WORKFLOW.md index 6fb8be5..c750427 100644 --- a/docs/BENCH_WORKFLOW.md +++ b/docs/BENCH_WORKFLOW.md @@ -11,6 +11,13 @@ 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 diff --git a/docs/FAN_CHARACTERIZATION.md b/docs/FAN_CHARACTERIZATION.md index be83ccd..cb04353 100644 --- a/docs/FAN_CHARACTERIZATION.md +++ b/docs/FAN_CHARACTERIZATION.md @@ -96,8 +96,11 @@ 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`, or the gate and tach sharing one pin. -Those rules only exclude pins known to be taken; they do not choose one. +(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: diff --git a/docs/TARGET_ESP32S3.md b/docs/TARGET_ESP32S3.md index 13f7b79..5afda75 100644 --- a/docs/TARGET_ESP32S3.md +++ b/docs/TARGET_ESP32S3.md @@ -63,6 +63,35 @@ 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 identified (HQ, 2026-09-29) as the VCC-GND YD-ESP32-S3, +whose 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. + +## 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 index da947a3..d46a6bd 100644 --- a/docs/TARGET_N16R8.md +++ b/docs/TARGET_N16R8.md @@ -11,7 +11,8 @@ silkscreen. Nothing here has been measured or exercised on the board. - 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 -- Onboard RGB LED: GPIO48 (silkscreen `RGB@IO48`) +- 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, @@ -24,14 +25,12 @@ silkscreen. Nothing here has been measured or exercised on the board. ## Configuration The overlay layers on the shared `sdkconfig.defaults`, which already selects -Octal SPI PSRAM at 80 MHz, 80 MHz flash, and no RF-switch GPIO. It changes -only three things: +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"` -- `CONFIG_DB_BOARD_RESERVED_GPIO=48`, so fixture wiring cannot claim the - onboard RGB LED pin. DragonBench never drives GPIO48. Other board profiles - do not reserve it. ```text idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.n16r8" set-target esp32s3 @@ -61,17 +60,44 @@ 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 (onboard RGB LED) and the +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: an onboard RGB LED with its data on GPIO48 +(silkscreen `RGB@IO48`). No power or enable GPIO is known, and none is +configured. + +Not confirmed from a primary source (Lonely Binary's documentation could not +be fetched from this environment): the LED part. Secondary summaries of +Lonely Binary's guide describe it as a WS2812 on GPIO48 and mention a pad +marked `RGB` that must be bridged. 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. Build-validated only. - Physical flash and boot: pending. -- Not validated: PSRAM detection and memory test at boot, reset and - auto-download behavior through either USB-C connector, which USB-C connector - reaches the USB-UART bridge and which reaches native USB, Wi-Fi, mDNS, - workload execution, fan characterization, and electrical characterization. +- Not validated: the status RGB LED, PSRAM detection and memory test at boot, + reset and auto-download behavior through either USB-C connector, which USB-C + connector reaches the USB-UART bridge and which reaches native USB, Wi-Fi, + mDNS, workload execution, 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/db_run.c b/firmware/common/db_run.c index c96783a..f6f7f07 100644 --- a/firmware/common/db_run.c +++ b/firmware/common/db_run.c @@ -96,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_run.h b/firmware/common/include/db_run.h index 333ed61..f2fd930 100644 --- a/firmware/common/include/db_run.h +++ b/firmware/common/include/db_run.h @@ -62,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 fcba199..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" "fan_characterization.c" + SRCS "main.c" "fan_characterization.c" "status_rgb.c" INCLUDE_DIRS "." REQUIRES common @@ -15,6 +15,7 @@ idf_component_register( esp_driver_gpio esp_driver_ledc esp_driver_pcnt + esp_driver_rmt esp_timer json ) diff --git a/firmware/targets/esp32s3/main/Kconfig.projbuild b/firmware/targets/esp32s3/main/Kconfig.projbuild index 7f36e98..d734f12 100644 --- a/firmware/targets/esp32s3/main/Kconfig.projbuild +++ b/firmware/targets/esp32s3/main/Kconfig.projbuild @@ -30,15 +30,27 @@ 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_BOARD_RESERVED_GPIO - int "GPIO wired to an onboard peripheral (-1 if none)" +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 - A GPIO the board itself wires to something, so experiment fixture - wiring must not claim it. DragonBench does not drive it. - Set per board by the sdkconfig.defaults. overlay. - ESP32-S3 N16R8 (Lonely Binary carrier): GPIO48, onboard RGB LED. + 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" diff --git a/firmware/targets/esp32s3/main/fan_characterization.c b/firmware/targets/esp32s3/main/fan_characterization.c index 659d047..58add52 100644 --- a/firmware/targets/esp32s3/main/fan_characterization.c +++ b/firmware/targets/esp32s3/main/fan_characterization.c @@ -41,8 +41,12 @@ #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 -#if CONFIG_DB_FAN_PWM_GATE_GPIO == CONFIG_DB_BOARD_RESERVED_GPIO || CONFIG_DB_FAN_TACH_GPIO == CONFIG_DB_BOARD_RESERVED_GPIO -#error "fan-characterization: GPIO collides with CONFIG_DB_BOARD_RESERVED_GPIO, which this board wires to an onboard peripheral" +// 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, diff --git a/firmware/targets/esp32s3/main/main.c b/firmware/targets/esp32s3/main/main.c index 5bd9648..85563a1 100644 --- a/firmware/targets/esp32s3/main/main.c +++ b/firmware/targets/esp32s3/main/main.c @@ -13,6 +13,7 @@ #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" @@ -63,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 @@ -289,6 +293,17 @@ static void format_parameters(const db_run_request_t *request, char *out, size_t 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; @@ -350,6 +365,7 @@ static void workload_task(void *unused) { 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()) { @@ -585,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"); @@ -1273,6 +1290,7 @@ 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(); @@ -1325,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/sdkconfig.defaults b/sdkconfig.defaults index 3d43c86..dd6ac61 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -17,6 +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.n16r8 b/sdkconfig.defaults.n16r8 index 7461c05..baa9e46 100644 --- a/sdkconfig.defaults.n16r8 +++ b/sdkconfig.defaults.n16r8 @@ -1,7 +1,8 @@ # 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 and no -# RF-switch GPIO come from sdkconfig.defaults; this board profile changes only -# the flash size, the reported identity, and the onboard RGB LED reservation. +# 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: @@ -9,5 +10,3 @@ CONFIG_DB_TARGET_NAME="esp32s3-n16r8" CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y # CONFIG_ESPTOOLPY_FLASHSIZE_8MB is not set -# GPIO48 drives the carrier's onboard RGB LED (silkscreen RGB@IO48). -CONFIG_DB_BOARD_RESERVED_GPIO=48 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 381f69b..08b8623 100644 --- a/tests/native/README.md +++ b/tests/native/README.md @@ -1,7 +1,8 @@ # Native common-state test `test_db_run.c` directly exercises the platform-neutral C workload registry, -validation, state transitions, run identity retention, and abort behavior. +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 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 index fec17f6..54a4331 100644 --- a/tests/test_board_profiles.py +++ b/tests/test_board_profiles.py @@ -21,11 +21,14 @@ FIXTURE_C = ROOT / "firmware/targets/esp32s3/main/fan_characterization.c" EXPECTED = { - # board: (CONFIG_DB_TARGET_NAME, flash size, PSRAM mode, RF-switch GPIO, board-reserved GPIO) - "n8r8": ("esp32s3-n8r8", "8MB", "OCT", -1, -1), - "n16r8": ("esp32s3-n16r8", "16MB", "OCT", -1, 48), - "tinys3d": ("esp32s3-tinys3d", "8MB", "QUAD", 38, -1), + # 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): @@ -43,7 +46,7 @@ def resolved(board): elif line.startswith("CONFIG_"): key, value = line.split("=", 1) settings[key] = value - for name in ("DB_RF_SWITCH_GPIO", "DB_BOARD_RESERVED_GPIO"): + for name in GPIO_SETTINGS: settings.setdefault(f"CONFIG_{name}", str(_kconfig_int_default(name))) return settings @@ -58,7 +61,7 @@ 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, reserved) in EXPECTED.items(): + 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}"') @@ -67,7 +70,10 @@ def test_board_configuration(self): 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_BOARD_RESERVED_GPIO"]), reserved) + 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") @@ -125,9 +131,8 @@ def tearDownClass(cls): 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}", - f"-DCONFIG_DB_RF_SWITCH_GPIO={settings['CONFIG_DB_RF_SWITCH_GPIO']}", - f"-DCONFIG_DB_BOARD_RESERVED_GPIO={settings['CONFIG_DB_BOARD_RESERVED_GPIO']}"] + 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, @@ -149,13 +154,22 @@ def test_hosted_ci_fixtures_pass_their_board_guards(self): self.assertAccepted("n8r8", 6, 7, sink=0) self.assertAccepted("tinys3d", 4, 5, sink=1) - def test_n16r8_rejects_the_onboard_rgb_led_gpio(self): - self.assertRejected("n16r8", 48, 7, "CONFIG_DB_BOARD_RESERVED_GPIO") - self.assertRejected("n16r8", 6, 48, "CONFIG_DB_BOARD_RESERVED_GPIO") + 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_gpio48_is_reserved_only_on_the_board_that_wires_it(self): - self.assertAccepted("n8r8", 48, 7) + 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") diff --git a/tests/test_fan_characterization_contract.py b/tests/test_fan_characterization_contract.py index 14cb951..54e20c5 100644 --- a/tests/test_fan_characterization_contract.py +++ b/tests/test_fan_characterization_contract.py @@ -65,6 +65,8 @@ def test_unconfigured_profile_refuses_to_build(self): 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): 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() From 72d69165183cd72b2678241238196ae74786543d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:05:14 +0000 Subject: [PATCH 12/16] docs: cite Lonely Binary's GPIO map for the N16R8 board Lonely Binary's own GPIO map for the ESP32-S3 Gold Edition confirms the N16R8 facts DragonBench relies on: GPIO48 is restricted and carries the RGB@IO48 LED beside an RGB solder pad, GPIO35-37 are unusable, the strapping, USB and console pins are restricted, and GPIO6/7 are plain GPIO. It also labels the two USB-C connectors UART and USB. No firmware change: the existing pin guards already reject every pin the map restricts. The LED part and the RGB pad state remain unconfirmed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqMRE3TFJJAGiE368AG91j --- docs/TARGET_N16R8.md | 45 ++++++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/docs/TARGET_N16R8.md b/docs/TARGET_N16R8.md index d46a6bd..a877e0c 100644 --- a/docs/TARGET_N16R8.md +++ b/docs/TARGET_N16R8.md @@ -4,13 +4,19 @@ > pending.** No image has been flashed to or booted on this board yet. Board facts below come from visual inspection of the physical board and its -silkscreen. Nothing here has been measured or exercised on the board. +silkscreen, and from Lonely Binary's interactive GPIO map for its ESP32-S3 +Gold Edition board (screenshot supplied by Dan, 2026-09-29). Nothing here has +been measured or exercised on the board. - 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). + Presumably `UART` reaches the USB-UART bridge and `USB` the native USB on + GPIO19/20; not checked on the board - Onboard RGB LED: GPIO48 (silkscreen `RGB@IO48`); see [Status RGB LED](#status-rgb-led) - UART console: TX GPIO43, RX GPIO44 @@ -18,7 +24,12 @@ silkscreen. Nothing here has been measured or exercised on the board. - 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 +- 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 - Profile overlay: `sdkconfig.defaults.n16r8`, reported target `esp32s3-n16r8` @@ -67,16 +78,18 @@ also the tach. ## Status RGB LED -Confirmed by visual inspection: an onboard RGB LED with its data on GPIO48 -(silkscreen `RGB@IO48`). No power or enable GPIO is known, and none is -configured. +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 (Lonely Binary's documentation could not -be fetched from this environment): the LED part. Secondary summaries of -Lonely Binary's guide describe it as a WS2812 on GPIO48 and mention a pad -marked `RGB` that must be bridged. 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. +Not confirmed from a primary source: the LED part (the map does not name it), +and whether the `RGB` pad has to be bridged, or is bridged on Dan's boards. +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 @@ -96,8 +109,8 @@ remains part of the board's baseline load. - ESP-IDF 5.3.5 builds: `baseline`, and `fan-characterization` with the wiring above. Build-validated only. - Physical flash and boot: pending. -- Not validated: the status RGB LED, PSRAM detection and memory test at boot, - reset and auto-download behavior through either USB-C connector, which USB-C - connector reaches the USB-UART bridge and which reaches native USB, Wi-Fi, - mDNS, workload execution, fan characterization, and electrical - characterization. \ No newline at end of file +- Not validated: the status RGB LED and the state of its `RGB` pad, PSRAM + detection and memory test at boot, reset and auto-download behavior through + either USB-C connector, that the `UART` and `USB` connectors reach the + USB-UART bridge and native USB as labelled, Wi-Fi, mDNS, workload + execution, fan characterization, and electrical characterization. \ No newline at end of file From 153c6d1a6a0274c73167ffa5b3e062730afa0c32 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:26:37 +0000 Subject: [PATCH 13/16] docs: record N8R8/N16R8 pin compatibility and confirm the YD identity Dan confirmed the N8R8 is the original VCC-GND YD-ESP32-S3, not a Lonely Binary board. Comparing VCC-GND's pin table and schematic V1.4 with Lonely Binary's GPIO map, the GPIOs DragonBench uses or rejects agree on both carriers: GPIO6/7 plain GPIO, GPIO48 the RGB LED, GPIO35-37 taken by Octal PSRAM, GPIO19/20 native USB, GPIO43/44 UART0, and 0/3/45/46 strapping. The docs record that equivalence and keep the carrier differences separate: flash size, USB-UART bridge, RGB electrical wiring, YD-only parts on already-rejected pins, and connector labels. No firmware or config change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqMRE3TFJJAGiE368AG91j --- docs/TARGET_ESP32S3.md | 36 +++++++++++++++++++++++++++++++++--- docs/TARGET_N16R8.md | 3 +++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/docs/TARGET_ESP32S3.md b/docs/TARGET_ESP32S3.md index 5afda75..59cd721 100644 --- a/docs/TARGET_ESP32S3.md +++ b/docs/TARGET_ESP32S3.md @@ -65,9 +65,10 @@ unvalidated on this module. ## Status RGB LED -The N8R8 board is identified (HQ, 2026-09-29) as the VCC-GND YD-ESP32-S3, -whose published schematic (YD-ESP32-S3-COREBOARD V1.4, 2022-09-23) includes the -CH343P bridge observed on this module. From that schematic: +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` @@ -90,6 +91,35 @@ 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 diff --git a/docs/TARGET_N16R8.md b/docs/TARGET_N16R8.md index a877e0c..555ebba 100644 --- a/docs/TARGET_N16R8.md +++ b/docs/TARGET_N16R8.md @@ -31,6 +31,9 @@ been measured or exercised on the board. 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 From b90e7132cdfa36e157e1695272b628b7790e0be9 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Tue, 29 Sep 2026 07:16:05 -0400 Subject: [PATCH 14/16] docs: record N16R8 baseline physical validation The n16r8/baseline image from 153c6d1 (ESP-IDF 5.3.5, clean tree) was flashed to and exercised on one Lonely Binary MCN16R8 board, with no fixture, fan, GPIO6/7 wiring or 24 V connected. Confirmed on the board: identity and build provenance, 16 MB flash, the 8 MB AP gen-3 PSRAM via ESP-IDF's Octal driver with the boot memory test passing, clean boots to ready, RESET and bridge-RTS resets, flashing through both USB-C connectors (UART bridge 1A86:7522, native USB 303A:1001), the status LED dark-then-green sequence (visual), the access point, mDNS, and the read-only API. Still unvalidated: station mode, host-driven reset over native USB, the bridge and LED parts, the RGB pad, workloads, the fan-characterization image, fixture wiring, and fan and electrical characterization. --- README.md | 8 +++-- docs/TARGET_N16R8.md | 72 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 7a4560e..aac9923 100644 --- a/README.md +++ b/README.md @@ -133,9 +133,11 @@ yet been exercised on hardware, and workload execution and electrical characterization remain unvalidated there too. See [TinyS3[D] profile](docs/TARGET_TINYS3D.md). -The N16R8 profile is build-validated only: physical flash and boot validation -is pending, and nothing has run on that board yet. See -[N16R8 profile](docs/TARGET_N16R8.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, the fan-characterization image 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 diff --git a/docs/TARGET_N16R8.md b/docs/TARGET_N16R8.md index 555ebba..d1d83f5 100644 --- a/docs/TARGET_N16R8.md +++ b/docs/TARGET_N16R8.md @@ -1,12 +1,16 @@ # ESP32-S3 N16R8 target profile -> **Validation state: build-validated only. Physical flash/boot validation is -> pending.** No image has been flashed to or booted on this board yet. +> **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. Fan +> characterization and the GPIO6/7 fixture wiring have not. 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). Nothing here has -been measured or exercised on the board. +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 @@ -15,8 +19,10 @@ been measured or exercised on the board. 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). - Presumably `UART` reaches the USB-UART bridge and `USB` the native USB on - GPIO19/20; not checked on the board + 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 @@ -88,7 +94,9 @@ 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), -and whether the `RGB` pad has to be bridged, or is bridged on Dan's boards. +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 @@ -110,10 +118,46 @@ remains part of the board's baseline load. ## Validation status - ESP-IDF 5.3.5 builds: `baseline`, and `fan-characterization` with the - wiring above. Build-validated only. -- Physical flash and boot: pending. -- Not validated: the status RGB LED and the state of its `RGB` pad, PSRAM - detection and memory test at boot, reset and auto-download behavior through - either USB-C connector, that the `UART` and `USB` connectors reach the - USB-UART bridge and native USB as labelled, Wi-Fi, mDNS, workload - execution, fan characterization, and electrical characterization. \ No newline at end of file + wiring above. The `fan-characterization` image is build-validated only. + +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. + +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, workload +execution, the `fan-characterization` image, the GPIO6/7 fixture wiring, fan +characterization, and electrical characterization. \ No newline at end of file From 30a4382ddf47f6d8dc6dd4257f2c409c515c23d6 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Tue, 29 Sep 2026 07:45:30 -0400 Subject: [PATCH 15/16] docs: record N16R8 fan-characterization no-load smoke test The n16r8/fan-characterization image from b90e713 (ESP-IDF 5.3.5, clean tree; gate GPIO6, sink level 1, tach GPIO7, PPR 0) was flashed over the UART connector and run with only a DMM on GPIO6: no fixture, stage, fan or 24 V, and GPIO7 unconnected. Observed: clean boot with the designed profile banner, correct identity, FAN_PWM_HOLD available, heater and fan control false. GPIO6 measured 0 V at idle, after runs and after reset; 0 V during a 0% hold and 3.26 V during a 100% hold. Holds passed with ordered phase_start, phase_end and run_complete events, and the status LED was off during each run. Still unvalidated: the PWM waveform between the endpoints (pending a logic analyzer), the shutdown handler, the fixture and external stage, real tach capture, PPR, and fan and electrical characterization. --- README.md | 10 ++++++---- docs/FAN_CHARACTERIZATION.md | 13 ++++++++---- docs/TARGET_N16R8.md | 38 ++++++++++++++++++++++++++++++------ 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index aac9923..2072464 100644 --- a/README.md +++ b/README.md @@ -136,13 +136,15 @@ characterization remain unvalidated there too. See 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, the fan-characterization image and electrical characterization -remain unvalidated there. See [N16R8 profile](docs/TARGET_N16R8.md). +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 GPIOs are not yet defined, and -no part of it has run on hardware. +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 diff --git a/docs/FAN_CHARACTERIZATION.md b/docs/FAN_CHARACTERIZATION.md index cb04353..2e294ad 100644 --- a/docs/FAN_CHARACTERIZATION.md +++ b/docs/FAN_CHARACTERIZATION.md @@ -10,8 +10,11 @@ [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; no -part of the fixture has run on hardware. The wiring is still to be defined, see +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)). No fixture has been +built or connected. The wiring is still to be defined, see [Open items](#open-items). The first specimen is the JumpJet Sanyo Denki `9GA0424P3J001` candidate. Its @@ -253,8 +256,10 @@ single hold. 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, LEDC output, PCNT accumulation, pad readback, and the - shutdown handler have been compiled but never run on hardware. +- 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. diff --git a/docs/TARGET_N16R8.md b/docs/TARGET_N16R8.md index d1d83f5..eaeb4f0 100644 --- a/docs/TARGET_N16R8.md +++ b/docs/TARGET_N16R8.md @@ -2,8 +2,10 @@ > **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. Fan -> characterization and the GPIO6/7 fixture wiring have not. See +> 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 @@ -118,7 +120,7 @@ remains part of the board's baseline load. ## Validation status - ESP-IDF 5.3.5 builds: `baseline`, and `fan-characterization` with the - wiring above. The `fan-characterization` image is build-validated only. + 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 @@ -156,8 +158,32 @@ wiring or 24 V supply was connected. Observed on that board: `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, workload -execution, the `fan-characterization` image, the GPIO6/7 fixture wiring, fan -characterization, and electrical characterization. \ No newline at end of file +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 From bc10a8ba16b3f82b9686d6efe16c25703af7408b Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Wed, 30 Sep 2026 11:58:20 +0000 Subject: [PATCH 16/16] docs: point fan characterization at the provisional N16R8 wiring FAN_CHARACTERIZATION.md still said the wiring was undefined and listed the fixture board and gate/tach GPIOs as open, although TARGET_N16R8.md already records the provisional Rev 0 wiring: N16R8, GPIO6 gate with CONFIG_DB_FAN_GATE_SINK_LEVEL=1, GPIO7 conditioned tach input, and PPR still 0 (unknown). The no-load smoke test used those pins. The status paragraph and Open items now name that wiring as provisional bench-fixture assignments, not JumpJet product GPIOs, and link to TARGET_N16R8.md. No complete fixture has been built or connected: the physical stage, tach conditioning, fan electrical limits, glitch filter and PPR remain open. Documentation only. --- docs/FAN_CHARACTERIZATION.md | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/docs/FAN_CHARACTERIZATION.md b/docs/FAN_CHARACTERIZATION.md index 2e294ad..b767b0f 100644 --- a/docs/FAN_CHARACTERIZATION.md +++ b/docs/FAN_CHARACTERIZATION.md @@ -13,9 +13,12 @@ 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)). No fixture has been -built or connected. The wiring is still to be defined, see -[Open items](#open-items). +[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 @@ -267,19 +270,23 @@ single hold. These must come from the bench before any wiring or first power: -1. Board: TinyS3[D], N8R8, or N16R8 module for the fixture. -2. Gate GPIO and tach GPIO, chosen from free, non-reserved pins on that board. - Check each pin's reset-state pull in the ESP32-S3 datasheet IO MUX table - against the gate resistor. -3. Stage topology and part: MOSFET or BJT, inverting or not (sets +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. -4. Fan PWM-lead electrical limits and its internal pull-up (plan §6), and + 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. -5. Tach output circuit, sink-current limit, the external 3.3 V pull-up value, +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. -6. Lead identity on the delivered specimen (plan §3) and the common-ground +5. Lead identity on the delivered specimen (plan §3) and the common-ground arrangement between the 24 V supply and the ESP32-S3. -7. Glitch-filter setting after scoping the tach line, if one is needed. -8. PPR and the evidence establishing it, only after it has been measured. +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.