From 2fb985892f7925fe5646f8867da3bcae66be2a26 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 15 Sep 2026 05:42:24 -0700 Subject: [PATCH 1/2] feat: add TinyS3D bring-up diagnostics --- .github/workflows/firmware-build.yml | 19 +++ .gitignore | 3 + components/jj_board/CMakeLists.txt | 2 + components/jj_board/include/jj_board.h | 25 +++ components/jj_board/jj_board.c | 30 ++++ components/jj_bringup/CMakeLists.txt | 6 + components/jj_bringup/include/jj_bringup.h | 44 ++++++ components/jj_bringup/jj_bringup.c | 87 +++++++++++ components/jj_bringup/jj_bringup_json.c | 82 ++++++++++ components/jj_portal/CMakeLists.txt | 4 +- components/jj_portal/include/jj_config.h | 7 + components/jj_portal/jj_config.c | 95 ++++++++++++ components/jj_portal/jj_portal.c | 103 ++----------- docs/hardware/tinys3d-bringup-diagnostics.md | 99 ++++++++++++ main/CMakeLists.txt | 2 +- main/app_main.c | 2 + tests/jj_bringup_adapter_host_test.c | 83 ++++++++++ tests/jj_bringup_host_test.c | 153 +++++++++++++++++++ tests/run_bringup_host_test.sh | 40 +++++ tests/stubs/bringup/esp_app_desc.h | 2 + tests/stubs/bringup/esp_chip_info.h | 2 + tests/stubs/bringup/esp_flash.h | 2 + tests/stubs/bringup/esp_heap_caps.h | 2 + tests/stubs/bringup/esp_netif.h | 2 + tests/stubs/bringup/esp_psram.h | 2 + tests/stubs/bringup/esp_system.h | 2 + tests/stubs/bringup/esp_timer.h | 2 + tests/stubs/bringup/esp_wifi.h | 2 + tests/stubs/bringup/esp_wifi_types.h | 2 + tests/stubs/bringup/idf_fixture.h | 29 ++++ tests/stubs/bringup/sdkconfig.h | 3 + tests/stubs/esp_err.h | 8 + 32 files changed, 851 insertions(+), 95 deletions(-) create mode 100644 components/jj_board/CMakeLists.txt create mode 100644 components/jj_board/include/jj_board.h create mode 100644 components/jj_board/jj_board.c create mode 100644 components/jj_bringup/CMakeLists.txt create mode 100644 components/jj_bringup/include/jj_bringup.h create mode 100644 components/jj_bringup/jj_bringup.c create mode 100644 components/jj_bringup/jj_bringup_json.c create mode 100644 components/jj_portal/include/jj_config.h create mode 100644 components/jj_portal/jj_config.c create mode 100644 docs/hardware/tinys3d-bringup-diagnostics.md create mode 100644 tests/jj_bringup_adapter_host_test.c create mode 100644 tests/jj_bringup_host_test.c create mode 100644 tests/run_bringup_host_test.sh create mode 100644 tests/stubs/bringup/esp_app_desc.h create mode 100644 tests/stubs/bringup/esp_chip_info.h create mode 100644 tests/stubs/bringup/esp_flash.h create mode 100644 tests/stubs/bringup/esp_heap_caps.h create mode 100644 tests/stubs/bringup/esp_netif.h create mode 100644 tests/stubs/bringup/esp_psram.h create mode 100644 tests/stubs/bringup/esp_system.h create mode 100644 tests/stubs/bringup/esp_timer.h create mode 100644 tests/stubs/bringup/esp_wifi.h create mode 100644 tests/stubs/bringup/esp_wifi_types.h create mode 100644 tests/stubs/bringup/idf_fixture.h create mode 100644 tests/stubs/bringup/sdkconfig.h create mode 100644 tests/stubs/esp_err.h diff --git a/.github/workflows/firmware-build.yml b/.github/workflows/firmware-build.yml index 5176e37..4a17f60 100644 --- a/.github/workflows/firmware-build.yml +++ b/.github/workflows/firmware-build.yml @@ -29,6 +29,25 @@ jobs: - name: Test heater interlock state machine run: ./tests/run_interlock_host_test.sh + - name: Check out pinned dragon-core for host fixtures + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: justinh-rahb/dragon-core + ref: 4e041d864763d468a50e9649807827dd83dd54bc + path: .test-deps/dragon-core + + - name: Check out matching ESP-IDF headers and JSON dependency + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: espressif/esp-idf + ref: v5.3 + path: .test-deps/esp-idf + + - name: Test candidate diagnostics and configuration + run: | + git -C .test-deps/esp-idf submodule update --init --depth 1 components/json/cJSON + DRAGON_CORE_PATH="$PWD/.test-deps/dragon-core" IDF_PATH="$PWD/.test-deps/esp-idf" bash tests/run_bringup_host_test.sh + - name: Check canonical product identity run: sh tests/check_identity_contract.sh diff --git a/.gitignore b/.gitignore index 5199072..3c3ae94 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ _autosave-* *.drl *.pos *.rpt + +# Read-only dependencies for host characterization fixtures +.test-deps/ diff --git a/components/jj_board/CMakeLists.txt b/components/jj_board/CMakeLists.txt new file mode 100644 index 0000000..6f3009e --- /dev/null +++ b/components/jj_board/CMakeLists.txt @@ -0,0 +1,2 @@ +idf_component_register(SRCS "jj_board.c" INCLUDE_DIRS "include") +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/components/jj_board/include/jj_board.h b/components/jj_board/include/jj_board.h new file mode 100644 index 0000000..d1a9a40 --- /dev/null +++ b/components/jj_board/include/jj_board.h @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include + +typedef enum { + JJ_GPIO_UNASSIGNED, JJ_GPIO_PROVISIONAL, JJ_GPIO_BLOCKED, JJ_GPIO_VERIFIED +} jj_gpio_status_t; + +typedef struct { + const char *role; + int gpio; // -1 means unavailable, never a usable assignment. + jj_gpio_status_t status; +} jj_board_role_t; + +typedef struct { + const char *profile; + bool production; + bool heater_available; + const jj_board_role_t *roles; + size_t role_count; +} jj_board_t; + +const jj_board_t *jj_board_profile(void); +const char *jj_gpio_status_name(jj_gpio_status_t status); diff --git a/components/jj_board/jj_board.c b/components/jj_board/jj_board.c new file mode 100644 index 0000000..851544f --- /dev/null +++ b/components/jj_board/jj_board.c @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "jj_board.h" + +static const jj_board_role_t roles[] = { + {"heater_enable", -1, JJ_GPIO_UNASSIGNED}, + {"fan_power", -1, JJ_GPIO_UNASSIGNED}, + {"fan_pwm", -1, JJ_GPIO_UNASSIGNED}, + {"fan_tach", -1, JJ_GPIO_UNASSIGNED}, + {"temperature_input", -1, JJ_GPIO_UNASSIGNED}, +}; + +const jj_board_t *jj_board_profile(void) +{ + // A build profile, not runtime detection of the connected module. + static const jj_board_t board = { + "tinys3d_candidate", false, false, roles, sizeof(roles) / sizeof(roles[0]) + }; + return &board; +} + +const char *jj_gpio_status_name(jj_gpio_status_t status) +{ + switch (status) { + case JJ_GPIO_UNASSIGNED: return "unassigned"; + case JJ_GPIO_PROVISIONAL: return "provisional"; + case JJ_GPIO_BLOCKED: return "blocked"; + case JJ_GPIO_VERIFIED: return "verified"; + default: return "unknown"; + } +} diff --git a/components/jj_bringup/CMakeLists.txt b/components/jj_bringup/CMakeLists.txt new file mode 100644 index 0000000..42aeafc --- /dev/null +++ b/components/jj_bringup/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "jj_bringup.c" "jj_bringup_json.c" + INCLUDE_DIRS "include" + REQUIRES jj_board json dc_wifi esp_app_format esp_hw_support spi_flash heap esp_netif esp_system esp_timer esp_wifi esp_psram +) +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/components/jj_bringup/include/jj_bringup.h b/components/jj_bringup/include/jj_bringup.h new file mode 100644 index 0000000..8bf39d3 --- /dev/null +++ b/components/jj_bringup/include/jj_bringup.h @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include "cJSON.h" +#include +#include + +typedef enum { + JJ_PSRAM_NOT_ATTEMPTED, JJ_PSRAM_UNAVAILABLE, JJ_PSRAM_INITIALIZED, JJ_PSRAM_UNKNOWN +} jj_psram_state_t; + +typedef struct { + const char *idf_version; + const char *project; + const char *version; + int chip_model; // ESP-IDF esp_chip_model_t numeric value. + unsigned chip_revision; // ESP-IDF: major * 100 + minor. + unsigned chip_cores; + int reset_reason; // ESP-IDF esp_reset_reason_t numeric value. + bool flash_known; + uint32_t flash_bytes; + const char *configured_flash_image_mode; + const char *configured_flash_image_frequency; + jj_psram_state_t psram_state; + uint32_t psram_bytes; + uint64_t sampled_at_ms; +} jj_boot_observation_t; + +typedef enum { JJ_OBSERVATION_UNKNOWN, JJ_OBSERVATION_FALSE, JJ_OBSERVATION_TRUE } jj_observation_t; +typedef struct { + uint64_t sampled_at_ms; + uint64_t sample_finished_at_ms; + uint32_t free_internal_8bit_bytes; + uint32_t minimum_free_internal_8bit_bytes; + const char *wifi_state; + jj_observation_t sta_associated; + jj_observation_t sta_ipv4_available; + jj_observation_t dhcp_client_started; +} jj_runtime_observation_t; + +// Called once before the portal starts; no probing or memory initialization. +void jj_bringup_init(void); +cJSON *jj_bringup_snapshot_json(void); +// Pure serialization boundary, shared with host fixtures. +cJSON *jj_bringup_json(const jj_boot_observation_t *boot, const jj_runtime_observation_t *runtime); diff --git a/components/jj_bringup/jj_bringup.c b/components/jj_bringup/jj_bringup.c new file mode 100644 index 0000000..d27092b --- /dev/null +++ b/components/jj_bringup/jj_bringup.c @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "jj_bringup.h" +#include "dc_wifi.h" +#include "esp_app_desc.h" +#include "esp_chip_info.h" +#include "esp_flash.h" +#include "esp_heap_caps.h" +#include "esp_netif.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "esp_wifi.h" +#include "sdkconfig.h" +#if CONFIG_SPIRAM +#include "esp_psram.h" +#endif + +static jj_boot_observation_t s_boot; + +void jj_bringup_init(void) +{ + const esp_app_desc_t *app = esp_app_get_description(); + esp_chip_info_t chip; + esp_chip_info(&chip); + s_boot = (jj_boot_observation_t) { + .idf_version = esp_get_idf_version(), .project = app->project_name, + .version = app->version, .chip_model = chip.model, + .chip_revision = chip.revision, .chip_cores = chip.cores, + .reset_reason = esp_reset_reason(), + .configured_flash_image_mode = CONFIG_ESPTOOLPY_FLASHMODE, + .configured_flash_image_frequency = CONFIG_ESPTOOLPY_FLASHFREQ, + .psram_state = JJ_PSRAM_NOT_ATTEMPTED, + .sampled_at_ms = (uint64_t)(esp_timer_get_time() / 1000), + }; + s_boot.flash_known = esp_flash_get_physical_size(NULL, &s_boot.flash_bytes) == ESP_OK; +#if CONFIG_SPIRAM + if (esp_psram_is_initialized()) { + s_boot.psram_state = JJ_PSRAM_INITIALIZED; + s_boot.psram_bytes = (uint32_t)esp_psram_get_size(); + } else { +#if CONFIG_SPIRAM_BOOT_INIT + s_boot.psram_state = JJ_PSRAM_UNAVAILABLE; +#else + s_boot.psram_state = JJ_PSRAM_NOT_ATTEMPTED; +#endif + } +#endif +} + +static const char *wifi_state_name(dc_wifi_state_t state) +{ + switch (state) { + case DC_WIFI_STATE_INIT: return "init"; + case DC_WIFI_STATE_STA_CONNECTING: return "sta_connecting"; + case DC_WIFI_STATE_STA_CONNECTED: return "sta_connected"; + case DC_WIFI_STATE_AP_PORTAL: return "ap_portal"; + default: return "unknown"; + } +} + +cJSON *jj_bringup_snapshot_json(void) +{ + jj_runtime_observation_t runtime = { + .sampled_at_ms = (uint64_t)(esp_timer_get_time() / 1000), + .free_internal_8bit_bytes = heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT), + .minimum_free_internal_8bit_bytes = heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT), + .wifi_state = wifi_state_name(dc_wifi_state()), + }; + wifi_ap_record_t ap; + esp_err_t association = esp_wifi_sta_get_ap_info(&ap); + if (association == ESP_OK) runtime.sta_associated = JJ_OBSERVATION_TRUE; + else if (association == ESP_ERR_WIFI_NOT_CONNECT) runtime.sta_associated = JJ_OBSERVATION_FALSE; + esp_netif_t *sta = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); + if (sta) { + esp_netif_ip_info_t ip = {0}; + if (esp_netif_get_ip_info(sta, &ip) == ESP_OK) { + // IP may be cached after disconnection; do not present that as live. + runtime.sta_ipv4_available = esp_netif_is_netif_up(sta) && ip.ip.addr != 0 && + runtime.sta_associated == JJ_OBSERVATION_TRUE ? JJ_OBSERVATION_TRUE : + runtime.sta_associated == JJ_OBSERVATION_UNKNOWN ? JJ_OBSERVATION_UNKNOWN : JJ_OBSERVATION_FALSE; + } + esp_netif_dhcp_status_t dhcp; + if (esp_netif_dhcpc_get_status(sta, &dhcp) == ESP_OK) + runtime.dhcp_client_started = dhcp == ESP_NETIF_DHCP_STARTED ? JJ_OBSERVATION_TRUE : JJ_OBSERVATION_FALSE; + } + runtime.sample_finished_at_ms = (uint64_t)(esp_timer_get_time() / 1000); + return jj_bringup_json(&s_boot, &runtime); +} diff --git a/components/jj_bringup/jj_bringup_json.c b/components/jj_bringup/jj_bringup_json.c new file mode 100644 index 0000000..4949cf6 --- /dev/null +++ b/components/jj_bringup/jj_bringup_json.c @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "jj_bringup.h" +#include "jj_board.h" + +static void observed_bool(cJSON *object, const char *key, jj_observation_t value) +{ + if (value == JJ_OBSERVATION_TRUE || value == JJ_OBSERVATION_FALSE) + cJSON_AddBoolToObject(object, key, value == JJ_OBSERVATION_TRUE); + else cJSON_AddNullToObject(object, key); +} + +static const char *psram_status(jj_psram_state_t state) +{ + switch (state) { + case JJ_PSRAM_NOT_ATTEMPTED: return "not_attempted"; + case JJ_PSRAM_UNAVAILABLE: return "unavailable"; + case JJ_PSRAM_INITIALIZED: return "initialized"; + default: return "unknown"; + } +} + +cJSON *jj_bringup_json(const jj_boot_observation_t *boot, const jj_runtime_observation_t *runtime) +{ + if (!boot || !runtime) return NULL; + cJSON *root = cJSON_CreateObject(); + if (!root) return NULL; + cJSON_AddNumberToObject(root, "schema", 1); + const jj_board_t *profile = jj_board_profile(); + cJSON *board = cJSON_AddObjectToObject(root, "board"); + cJSON_AddStringToObject(board, "provenance", "configured_profile_not_detection"); + cJSON_AddStringToObject(board, "profile", profile->profile); + cJSON_AddStringToObject(board, "status", "candidate"); + cJSON_AddBoolToObject(board, "production", profile->production); + cJSON_AddBoolToObject(board, "heater_available", profile->heater_available); + cJSON *roles = cJSON_AddArrayToObject(board, "gpio_roles"); + for (size_t i = 0; i < profile->role_count; ++i) { + const jj_board_role_t *role = &profile->roles[i]; + cJSON *item = cJSON_CreateObject(); + cJSON_AddStringToObject(item, "role", role->role); + if (role->gpio < 0) cJSON_AddNullToObject(item, "gpio"); + else cJSON_AddNumberToObject(item, "gpio", role->gpio); + cJSON_AddStringToObject(item, "status", jj_gpio_status_name(role->status)); + cJSON_AddItemToArray(roles, item); + } + cJSON *b = cJSON_AddObjectToObject(root, "boot"); + cJSON_AddStringToObject(b, "provenance", "esp_idf_boot_snapshot"); + cJSON_AddNumberToObject(b, "sampled_at_uptime_ms", (double)boot->sampled_at_ms); + cJSON_AddStringToObject(b, "idf_version", boot->idf_version); + cJSON_AddStringToObject(b, "project", boot->project); + cJSON_AddStringToObject(b, "version", boot->version); + cJSON_AddNumberToObject(b, "chip_model_id", boot->chip_model); + cJSON_AddNumberToObject(b, "chip_revision_major", boot->chip_revision / 100); + cJSON_AddNumberToObject(b, "chip_revision_minor", boot->chip_revision % 100); + cJSON_AddNumberToObject(b, "chip_cores", boot->chip_cores); + cJSON_AddNumberToObject(b, "reset_reason_id", boot->reset_reason); + if (boot->flash_known) cJSON_AddNumberToObject(b, "detected_flash_bytes", boot->flash_bytes); + else cJSON_AddNullToObject(b, "detected_flash_bytes"); + cJSON_AddStringToObject(b, "configured_flash_image_mode", boot->configured_flash_image_mode); + cJSON_AddStringToObject(b, "configured_flash_image_frequency", boot->configured_flash_image_frequency); + cJSON *psram = cJSON_AddObjectToObject(b, "psram"); + cJSON_AddStringToObject(psram, "initialization", psram_status(boot->psram_state)); + cJSON_AddStringToObject(psram, "physical_presence", "unknown"); + // Successful initialization is positive evidence. Disabled/failed is not absence. + if (boot->psram_state == JJ_PSRAM_INITIALIZED) { + cJSON_SetValuestring(cJSON_GetObjectItemCaseSensitive(psram, "physical_presence"), "detected"); + cJSON_AddNumberToObject(psram, "detected_bytes", boot->psram_bytes); + } else cJSON_AddNullToObject(psram, "detected_bytes"); + cJSON_AddStringToObject(psram, "self_test", "not_attempted"); + cJSON *r = cJSON_AddObjectToObject(root, "runtime"); + cJSON_AddStringToObject(r, "provenance", "esp_idf_and_dc_wifi_request_sample"); + cJSON_AddNumberToObject(r, "sampled_at_uptime_ms", (double)runtime->sampled_at_ms); + cJSON_AddNumberToObject(r, "sample_finished_at_uptime_ms", (double)runtime->sample_finished_at_ms); + cJSON_AddNumberToObject(r, "free_internal_8bit_heap_bytes", runtime->free_internal_8bit_bytes); + cJSON_AddNumberToObject(r, "minimum_free_internal_8bit_heap_bytes", runtime->minimum_free_internal_8bit_bytes); + cJSON *wifi = cJSON_AddObjectToObject(r, "wifi"); + cJSON_AddStringToObject(wifi, "core_state", runtime->wifi_state); + cJSON_AddStringToObject(wifi, "provisioning_completion", "unknown"); + observed_bool(wifi, "sta_associated", runtime->sta_associated); + observed_bool(wifi, "sta_ipv4_available", runtime->sta_ipv4_available); + observed_bool(wifi, "dhcp_client_started", runtime->dhcp_client_started); + return root; +} diff --git a/components/jj_portal/CMakeLists.txt b/components/jj_portal/CMakeLists.txt index 578bb6d..5e9617c 100644 --- a/components/jj_portal/CMakeLists.txt +++ b/components/jj_portal/CMakeLists.txt @@ -1,6 +1,6 @@ idf_component_register( - SRCS "jj_portal.c" + SRCS "jj_portal.c" "jj_config.c" INCLUDE_DIRS "include" - REQUIRES jj_identity jj_interlock dc_portal dc_prusa dc_wifi json esp_http_server app_update nvs_flash + REQUIRES jj_bringup jj_identity jj_interlock dc_portal dc_prusa dc_wifi json esp_http_server app_update nvs_flash ) target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/components/jj_portal/include/jj_config.h b/components/jj_portal/include/jj_config.h new file mode 100644 index 0000000..9a9526f --- /dev/null +++ b/components/jj_portal/include/jj_config.h @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include "cJSON.h" +#include "esp_err.h" +#include +cJSON *jj_config_describe(void *ctx); +esp_err_t jj_config_apply(const cJSON *values, void *ctx, char *message, size_t message_size); diff --git a/components/jj_portal/jj_config.c b/components/jj_portal/jj_config.c new file mode 100644 index 0000000..a075452 --- /dev/null +++ b/components/jj_portal/jj_config.c @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "jj_config.h" +#include "dc_prusa.h" +#include +#include +#include +#include + +static cJSON *field(const char *key, const char *label, const char *type, + const char *value, bool secret) +{ + cJSON *item = cJSON_CreateObject(); + cJSON_AddStringToObject(item, "key", key); + cJSON_AddStringToObject(item, "label", label); + cJSON_AddStringToObject(item, "type", type); + cJSON_AddStringToObject(item, "value", secret ? "" : value); + if (secret) cJSON_AddBoolToObject(item, "secret", true); + return item; +} + +cJSON *jj_config_describe(void *ctx) +{ + (void)ctx; + dc_prusa_config_t config = {0}; + (void)dc_prusa_get_config(&config); + char port[8]; + snprintf(port, sizeof(port), "%u", (unsigned)config.port); + cJSON *root = cJSON_CreateObject(); + cJSON *sections = cJSON_AddArrayToObject(root, "sections"); + cJSON *section = cJSON_CreateObject(); + cJSON_AddStringToObject(section, "title", "PrusaLink (read-only)"); + cJSON *fields = cJSON_AddArrayToObject(section, "fields"); + cJSON_AddItemToArray(fields, field("pr_host", "Printer host", "text", config.host, false)); + cJSON_AddItemToArray(fields, field("pr_port", "Port", "number", port, false)); + cJSON_AddItemToArray(fields, field("pr_key", "API key (blank keeps saved key)", + "password", "", true)); + cJSON_AddItemToArray(sections, section); + return root; +} + +static bool valid_host(const char *host) +{ + if (!host || !host[0] || strlen(host) >= sizeof(((dc_prusa_config_t *)0)->host)) + return false; + for (const unsigned char *p = (const unsigned char *)host; *p; ++p) + if (*p <= 0x20 || *p == '/' || *p == '\\' || *p == ':' || *p == '#') return false; + return true; +} + +esp_err_t jj_config_apply(const cJSON *values, void *ctx, + char *message, size_t message_size) +{ + (void)ctx; + dc_prusa_config_t config = {0}; + (void)dc_prusa_get_config(&config); + const cJSON *host = cJSON_GetObjectItemCaseSensitive(values, "pr_host"); + const cJSON *port = cJSON_GetObjectItemCaseSensitive(values, "pr_port"); + const cJSON *key = cJSON_GetObjectItemCaseSensitive(values, "pr_key"); + if (!cJSON_IsString(host) || !valid_host(host->valuestring)) { + snprintf(message, message_size, "Printer host is required and must not contain a URL path."); + return ESP_ERR_INVALID_ARG; + } + long parsed_port = config.port ? config.port : DC_PRUSA_DEFAULT_PORT; + if (cJSON_IsNumber(port)) { + parsed_port = port->valueint; + if (port->valuedouble != parsed_port) parsed_port = -1; + } else if (cJSON_IsString(port)) { + char *end = NULL; + errno = 0; + parsed_port = strtol(port->valuestring, &end, 10); + if (errno || !end || *end) parsed_port = -1; + } else { + parsed_port = -1; + } + if (parsed_port < 1 || parsed_port > UINT16_MAX) { + snprintf(message, message_size, "PrusaLink port must be 1-65535."); + return ESP_ERR_INVALID_ARG; + } + if (key && !cJSON_IsString(key)) { + snprintf(message, message_size, "PrusaLink API key must be text."); + return ESP_ERR_INVALID_ARG; + } + if (key && strlen(key->valuestring) >= sizeof(config.api_key)) { + snprintf(message, message_size, "PrusaLink API key is too long."); + return ESP_ERR_INVALID_ARG; + } + snprintf(config.host, sizeof(config.host), "%s", host->valuestring); + config.port = (uint16_t)parsed_port; + if (key && key->valuestring[0]) + snprintf(config.api_key, sizeof(config.api_key), "%s", key->valuestring); + esp_err_t err = dc_prusa_set_config(&config); + if (err == ESP_OK) + snprintf(message, message_size, "PrusaLink settings saved; restart to apply."); + return err; +} diff --git a/components/jj_portal/jj_portal.c b/components/jj_portal/jj_portal.c index a0d49d3..0abc433 100644 --- a/components/jj_portal/jj_portal.c +++ b/components/jj_portal/jj_portal.c @@ -1,5 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "jj_portal.h" +#include "jj_config.h" +#include "jj_bringup.h" #include "cJSON.h" #include "dc_portal.h" #include "dc_prusa.h" @@ -8,7 +10,6 @@ #include "esp_http_server.h" #include "esp_log.h" #include "esp_mac.h" -#include #include #include #include @@ -49,7 +50,7 @@ static esp_err_t info_get(httpd_req_t *req) static esp_err_t state_get(httpd_req_t *req) { dc_prusa_status_t printer = {0}; - (void)dc_prusa_get_status(&printer); + const esp_err_t printer_result = dc_prusa_get_status(&printer); const jj_outputs_t output = jj_interlock_snapshot(s_interlock); cJSON *root = cJSON_CreateObject(); cJSON_AddNumberToObject(root, "api_version", 2); @@ -79,6 +80,9 @@ static esp_err_t state_get(httpd_req_t *req) cJSON_AddStringToObject(safety, "interlock", jj_block_reason_str(output.block_reason)); cJSON *prusa = cJSON_AddObjectToObject(root, "printer"); cJSON_AddStringToObject(prusa, "source", "prusalink"); + if (printer_result == ESP_OK && printer.status_age_ms != UINT32_MAX) + cJSON_AddNumberToObject(prusa, "status_age_ms", printer.status_age_ms); + else cJSON_AddNullToObject(prusa, "status_age_ms"); cJSON_AddStringToObject(prusa, "connection", dc_prusa_state_str(printer.state)); cJSON_AddStringToObject(prusa, "state", printer.printer_state); if (printer.online) { @@ -88,6 +92,9 @@ static esp_err_t state_get(httpd_req_t *req) cJSON_AddNullToObject(prusa, "bed_temperature_c"); cJSON_AddNullToObject(prusa, "bed_target_c"); } + cJSON *diagnostics = jj_bringup_snapshot_json(); + if (!diagnostics) { cJSON_Delete(root); return ESP_ERR_NO_MEM; } + cJSON_AddItemToObject(root, "diagnostics", diagnostics); return send_json(req, root); } @@ -114,94 +121,6 @@ static esp_err_t register_routes(httpd_handle_t server, void *ctx) return ESP_OK; } -static cJSON *field(const char *key, const char *label, const char *type, - const char *value, bool secret) -{ - cJSON *item = cJSON_CreateObject(); - cJSON_AddStringToObject(item, "key", key); - cJSON_AddStringToObject(item, "label", label); - cJSON_AddStringToObject(item, "type", type); - cJSON_AddStringToObject(item, "value", secret ? "" : value); - if (secret) cJSON_AddBoolToObject(item, "secret", true); - return item; -} - -static cJSON *describe_product(void *ctx) -{ - (void)ctx; - dc_prusa_config_t config = {0}; - (void)dc_prusa_get_config(&config); - char port[8]; - snprintf(port, sizeof(port), "%u", (unsigned)config.port); - cJSON *root = cJSON_CreateObject(); - cJSON *sections = cJSON_AddArrayToObject(root, "sections"); - cJSON *section = cJSON_CreateObject(); - cJSON_AddStringToObject(section, "title", "PrusaLink (read-only)"); - cJSON *fields = cJSON_AddArrayToObject(section, "fields"); - cJSON_AddItemToArray(fields, field("pr_host", "Printer host", "text", config.host, false)); - cJSON_AddItemToArray(fields, field("pr_port", "Port", "number", port, false)); - cJSON_AddItemToArray(fields, field("pr_key", "API key (blank keeps saved key)", - "password", "", true)); - cJSON_AddItemToArray(sections, section); - return root; -} - -static bool valid_host(const char *host) -{ - if (!host || !host[0] || strlen(host) >= sizeof(((dc_prusa_config_t *)0)->host)) - return false; - for (const unsigned char *p = (const unsigned char *)host; *p; ++p) - if (*p <= 0x20 || *p == '/' || *p == '\\' || *p == ':' || *p == '#') return false; - return true; -} - -static esp_err_t apply_product(const cJSON *values, void *ctx, - char *message, size_t message_size) -{ - (void)ctx; - dc_prusa_config_t config = {0}; - (void)dc_prusa_get_config(&config); - const cJSON *host = cJSON_GetObjectItemCaseSensitive(values, "pr_host"); - const cJSON *port = cJSON_GetObjectItemCaseSensitive(values, "pr_port"); - const cJSON *key = cJSON_GetObjectItemCaseSensitive(values, "pr_key"); - if (!cJSON_IsString(host) || !valid_host(host->valuestring)) { - snprintf(message, message_size, "Printer host is required and must not contain a URL path."); - return ESP_ERR_INVALID_ARG; - } - long parsed_port = config.port ? config.port : DC_PRUSA_DEFAULT_PORT; - if (cJSON_IsNumber(port)) { - parsed_port = port->valueint; - if (port->valuedouble != parsed_port) parsed_port = -1; - } else if (cJSON_IsString(port)) { - char *end = NULL; - errno = 0; - parsed_port = strtol(port->valuestring, &end, 10); - if (errno || !end || *end) parsed_port = -1; - } else { - parsed_port = -1; - } - if (parsed_port < 1 || parsed_port > UINT16_MAX) { - snprintf(message, message_size, "PrusaLink port must be 1-65535."); - return ESP_ERR_INVALID_ARG; - } - if (key && !cJSON_IsString(key)) { - snprintf(message, message_size, "PrusaLink API key must be text."); - return ESP_ERR_INVALID_ARG; - } - if (key && strlen(key->valuestring) >= sizeof(config.api_key)) { - snprintf(message, message_size, "PrusaLink API key is too long."); - return ESP_ERR_INVALID_ARG; - } - snprintf(config.host, sizeof(config.host), "%s", host->valuestring); - config.port = (uint16_t)parsed_port; - if (key && key->valuestring[0]) - snprintf(config.api_key, sizeof(config.api_key), "%s", key->valuestring); - esp_err_t err = dc_prusa_set_config(&config); - if (err == ESP_OK) - snprintf(message, message_size, "PrusaLink settings saved; restart to apply."); - return err; -} - static bool authorize(httpd_req_t *req, void *ctx) { (void)ctx; @@ -261,8 +180,8 @@ esp_err_t jj_portal_start(jj_interlock_t *interlock) .product = JJ_IDENTITY_PRODUCT_ID, .display_name = JJ_IDENTITY_DISPLAY_NAME, .register_product_routes = register_routes, - .describe_product = describe_product, - .apply_product = apply_product, + .describe_product = jj_config_describe, + .apply_product = jj_config_apply, .authorize = authorize, .guard_operation = guard_operation, .validate_image = validate_image, diff --git a/docs/hardware/tinys3d-bringup-diagnostics.md b/docs/hardware/tinys3d-bringup-diagnostics.md new file mode 100644 index 0000000..6e0dc51 --- /dev/null +++ b/docs/hardware/tinys3d-bringup-diagnostics.md @@ -0,0 +1,99 @@ +# TinyS3D bring-up scaffolding + +This feature branch configures the `tinys3d_candidate` profile. It does not detect +which module is attached or select a production board. All five product roles +(heater enable, fan power, fan PWM, fan tach, temperature input) remain unassigned: +`gpio = -1` internally and `null` in JSON. No GPIO driver or output path exists. +The existing interlock, control policy and OTA guards remain authoritative. + +## Capture on the next bench session + +Use the existing `GET /api/v2/state` and save the complete JSON response with a +host timestamp. `diagnostics.schema` is 1; there is no additional server or debug +mutation. Capture once after boot and repeatedly during provisioning/reconnection. +These instructions are future bench work; host fixtures are not device evidence. + +| Surface | Meaning | +|---|---| +| `diagnostics.board` | Configured candidate profile, not detected module identity; production and heater availability are false | +| `diagnostics.boot` | Immutable startup observations, sampled before networking starts; timestamp is milliseconds since boot | +| `idf_version`, `project`, `version` | Runtime IDF version and application build identity from ESP-IDF; a dirty build is not a reproducible release | +| `chip_model_id`, revision major/minor, cores | ESP-IDF chip information; model ID follows that IDF version's `esp_chip_model_t` enum | +| `reset_reason_id` | ESP-IDF `esp_reset_reason_t`; observation only, no recovery-policy changes | +| `detected_flash_bytes` | `esp_flash_get_physical_size`, not image-header capacity; null on query failure | +| `configured_flash_image_mode`, `configured_flash_image_frequency` | ESPTOOLPY image-header strings, not measured bus mode/frequency; IDF may encode a conservative boot mode/frequency | +| `psram.initialization` | `not_attempted` with support disabled or boot init disabled; `unavailable` if boot init enabled but memory not initialized; `initialized` on positive API evidence; unknown reserved for unavailable evidence | +| `psram.physical_presence`, `detected_bytes` | Unknown/null unless initialization succeeded; failure or disabled support does not establish physical absence | +| `psram.self_test` | Always `not_attempted`; no allocation test or stress test is implemented | +| `diagnostics.runtime` | Fresh request-time queries, bracketed by uptime timestamps; sequential samples, not an atomic hardware snapshot | +| `free_internal_8bit_heap_bytes` | Current free heap with both INTERNAL and 8BIT capabilities, in bytes | +| `minimum_free_internal_8bit_heap_bytes` | ESP-IDF allocator's sum of per-region low-water marks for those capabilities; not necessarily a simultaneous global minimum | +| `wifi.core_state` | Pinned dc_wifi state; `sta_connected` is set on GOT_IP, not merely association | +| `wifi.sta_associated` | Current `esp_wifi_sta_get_ap_info` result: true on success, false on NOT_CONNECT, null on other failures | +| `wifi.sta_ipv4_available` | Nonzero IPv4 plus up STA netif plus observed association; cached IP after disconnection is not success; null when required evidence is unavailable | +| `wifi.dhcp_client_started` | DHCP client running, not proof of a lease; combine with IPv4 availability for the current shared DHCP path | +| `wifi.provisioning_completion` | Always `unknown`: pinned infrastructure has no durable completion marker | +| `printer.status_age_ms` | Existing dc_prusa sample age; null for unavailable/error, preserving its authoritative freshness semantics | + +No credentials, SSID, IP address, full MAC, or new device identifier are included. +A client must compare uptime only within one boot. Save build identity and host +capture times to distinguish reboots. These samples do not promise an event +history or capture brief association/IP transitions between requests. + +## Provisioning semantics and shared follow-up + +Continue to use the shared provisioning UI and `/api/v1/provisioning/wifi`. +At dragon-core `4e041d864763d468a50e9649807827dd83dd54bc`, the HTTP handler sends +`ok/rebooting` before `dc_wifi_save_creds_and_reboot` persists settings. Therefore +that response, an AP starting, or a scan completing cannot prove provisioning +completion. The diagnostic reports unknown, never a fabricated success. + +A separate upstream follow-up could provide a persistence outcome and timestamped +association/GOT_IP observations through a shared read-only snapshot. No upstream +code changes are included here. The next bench session must distinguish submission, +reboot, actual STA association and IP acquisition with the available evidence. + +## PSRAM boundary + +The default build retains the baseline configuration: PSRAM support is disabled, +flash image capacity remains 4 MiB, and no memory mode or pin is guessed. Firmware +never calls a PSRAM initialization API or changes application behavior based on +PSRAM. The conditional read-only adapter can describe a future independently +verified configuration. Such a configuration must tolerate absent memory before +being used; this task supplies no opt-in electrical profile and makes no boot +success claim for arbitrary PSRAM-enabled builds. + +## GPIO review table + +These are review considerations from the existing characterization record, not +verified product assignments. Status vocabulary is `unassigned`, `provisional`, +`blocked`, `verified`; adding the word verified to the enum verifies no GPIO. + +| Candidate resource | Review status / remaining evidence | +|---|---| +| All actuator roles | Unassigned; no candidate GPIO selected; output-at-reset, gate bias, power-domain and boot hazards must be verified before use | +| IO0 / IO3 | Provisional reference: strapping pins; check boot levels and external loading | +| Native USB / JTAG resources | Blocked from assignment pending module pinout and debug/USB conflict review; no numerical assignment introduced | +| ADC-labelled header resources | Provisional silicon/reference capability only; ADC range, attenuation, noise, onboard sharing and actual module suitability unverified | +| IO8 / IO9 | Provisional shared onboard I2C reference; do not assume free ADC/GPIO suitability | +| IO43 / IO44 | Provisional console reference; inspect boot traffic and reset behavior before reuse | +| Heater / fan candidate pins | Unassigned; silicon output capability is not evidence of cold-safe product suitability | +| Mounting / header numbering | Unresolved; dimensions and mechanical/electrical mapping require specimen verification | + +No onboard LED, antenna select, ADC, heater or fan is configured by this scaffold. + +## Host validation + +`tests/run_bringup_host_test.sh` uses the actual pinned dragon-core public Prusa +header and the cJSON submodule from the same ESP-IDF checkout used by firmware CI. +It rejects mismatched or modified dependency inputs. Adapter fixtures exercise +support-disabled, boot-init-disabled and boot-init-enabled branches, plus unknown, +associated-without-IP, connected and disconnected-with-cached-IP samples. Persistence and ESP-IDF +observations are explicitly simulated; no Wi-Fi hardware or physical HIL is used. + +The product's existing secret contract is preserved: omitted or empty `pr_key` +retains, nonempty replaces, non-string rejects. There is no key-clear mutation in +this product callback; factory reset remains the existing full-config clear path. +Shared STA provisioning treats omitted/empty passwords as an open-network password; +AP configuration has its own default-password semantics. Those shared APIs are not +reimplemented or changed here. Descriptions redact saved product keys. diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 5d4c183..bedfedf 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,6 +1,6 @@ idf_component_register( SRCS "app_main.c" INCLUDE_DIRS "." - REQUIRES jj_identity jj_interlock jj_portal dc_evlog dc_prusa dc_wifi nvs_flash app_update + REQUIRES jj_bringup jj_identity jj_interlock jj_portal dc_evlog dc_prusa dc_wifi nvs_flash app_update ) target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/main/app_main.c b/main/app_main.c index 1365cd1..89ba967 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -6,6 +6,7 @@ #include "dc_prusa.h" #include "dc_wifi.h" #include "jj_identity.h" +#include "jj_bringup.h" #include "jj_interlock.h" #include "jj_portal.h" #include "esp_check.h" @@ -57,6 +58,7 @@ void app_main(void) { dc_evlog_console_init(); dc_evlog_init(); + jj_bringup_init(); ESP_LOGI(TAG, "%s cold-safe foundation starting", JJ_IDENTITY_DISPLAY_NAME); esp_err_t err = nvs_flash_init(); if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { diff --git a/tests/jj_bringup_adapter_host_test.c b/tests/jj_bringup_adapter_host_test.c new file mode 100644 index 0000000..c51af46 --- /dev/null +++ b/tests/jj_bringup_adapter_host_test.c @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Simulated ESP-IDF return values: no physical measurements. +#include "idf_fixture.h" +#include "dc_wifi.h" +#include "jj_bringup.h" +#include +#include +#include + +static bool initialized, netif_present, up; +static esp_err_t association = ESP_FAIL, ip_result = ESP_FAIL, flash_result = ESP_FAIL; +static uint32_t ip_addr; +static int64_t clock_us; +static esp_netif_t netif; +static dc_wifi_state_t core_state; +const esp_app_desc_t *esp_app_get_description(void) { static const esp_app_desc_t app = {"fixture", "fixture"}; return &app; } +void esp_chip_info(esp_chip_info_t *chip) { *chip = (esp_chip_info_t){9, 2, 2}; } +const char *esp_get_idf_version(void) { return "fixture"; } +int esp_reset_reason(void) { return 1; } +int64_t esp_timer_get_time(void) { clock_us += 1000; return clock_us; } +esp_err_t esp_flash_get_physical_size(void *chip, uint32_t *size) { assert(!chip); *size = 8388608; return flash_result; } +bool esp_psram_is_initialized(void) { return initialized; } +size_t esp_psram_get_size(void) { assert(initialized); return 8192; } +size_t heap_caps_get_free_size(uint32_t caps) { assert(caps == (MALLOC_CAP_8BIT | MALLOC_CAP_INTERNAL)); return 100; } +size_t heap_caps_get_minimum_free_size(uint32_t caps) { assert(caps == (MALLOC_CAP_8BIT | MALLOC_CAP_INTERNAL)); return 50; } +esp_err_t esp_wifi_sta_get_ap_info(wifi_ap_record_t *ap) { (void)ap; return association; } +esp_netif_t *esp_netif_get_handle_from_ifkey(const char *key) { assert(!strcmp(key, "WIFI_STA_DEF")); return netif_present ? &netif : NULL; } +esp_err_t esp_netif_get_ip_info(esp_netif_t *n, esp_netif_ip_info_t *ip) { assert(n == &netif); ip->ip.addr = ip_addr; return ip_result; } +bool esp_netif_is_netif_up(esp_netif_t *n) { assert(n == &netif); return up; } +esp_err_t esp_netif_dhcpc_get_status(esp_netif_t *n, esp_netif_dhcp_status_t *status) { assert(n == &netif); *status = ESP_NETIF_DHCP_STARTED; return ESP_OK; } +dc_wifi_state_t dc_wifi_state(void) { return core_state; } +static cJSON *get(cJSON *obj, const char *key) { cJSON *value = cJSON_GetObjectItemCaseSensitive(obj, key); assert(value); return value; } +static void check_network(jj_observation_t expected_assoc, jj_observation_t expected_ip) +{ + cJSON *root = jj_bringup_snapshot_json(); + cJSON *runtime = get(root, "runtime"), *wifi = get(runtime, "wifi"); + cJSON *assoc = get(wifi, "sta_associated"), *ip = get(wifi, "sta_ipv4_available"); + assert(expected_assoc == JJ_OBSERVATION_UNKNOWN ? cJSON_IsNull(assoc) : + expected_assoc == JJ_OBSERVATION_TRUE ? cJSON_IsTrue(assoc) : cJSON_IsFalse(assoc)); + assert(expected_ip == JJ_OBSERVATION_UNKNOWN ? cJSON_IsNull(ip) : + expected_ip == JJ_OBSERVATION_TRUE ? cJSON_IsTrue(ip) : cJSON_IsFalse(ip)); + assert(get(runtime, "sample_finished_at_uptime_ms")->valuedouble >= get(runtime, "sampled_at_uptime_ms")->valuedouble); + cJSON_Delete(root); +} +int main(void) +{ + jj_bringup_init(); + cJSON *root = jj_bringup_snapshot_json(); + cJSON *boot = get(root, "boot"), *psram = get(boot, "psram"); +#if CONFIG_SPIRAM && CONFIG_SPIRAM_BOOT_INIT + assert(!strcmp(get(psram, "initialization")->valuestring, "unavailable")); +#else + assert(!strcmp(get(psram, "initialization")->valuestring, "not_attempted")); +#endif + assert(cJSON_IsNull(get(psram, "detected_bytes"))); + assert(cJSON_IsNull(get(boot, "detected_flash_bytes"))); + cJSON_Delete(root); + initialized = true; flash_result = ESP_OK; + jj_bringup_init(); // Simulate a different boot, never called twice by firmware. + root = jj_bringup_snapshot_json(); boot = get(root, "boot"); psram = get(boot, "psram"); +#if CONFIG_SPIRAM + assert(!strcmp(get(psram, "initialization")->valuestring, "initialized")); + assert(get(psram, "detected_bytes")->valueint == 8192); +#else + assert(!strcmp(get(psram, "initialization")->valuestring, "not_attempted")); +#endif + assert(get(boot, "detected_flash_bytes")->valueint == 8388608); + cJSON_Delete(root); + check_network(JJ_OBSERVATION_UNKNOWN, JJ_OBSERVATION_UNKNOWN); + netif_present = true; ip_result = ESP_OK; association = ESP_ERR_WIFI_NOT_CONNECT; + check_network(JJ_OBSERVATION_FALSE, JJ_OBSERVATION_FALSE); + association = ESP_OK; up = true; + check_network(JJ_OBSERVATION_TRUE, JJ_OBSERVATION_FALSE); // associated, no lease + ip_addr = 1; core_state = DC_WIFI_STATE_STA_CONNECTED; + check_network(JJ_OBSERVATION_TRUE, JJ_OBSERVATION_TRUE); + association = ESP_ERR_WIFI_NOT_CONNECT; // stale cached IP must not mean connected + check_network(JJ_OBSERVATION_FALSE, JJ_OBSERVATION_FALSE); + association = ESP_FAIL; + check_network(JJ_OBSERVATION_UNKNOWN, JJ_OBSERVATION_UNKNOWN); + association = ESP_OK; ip_result = ESP_FAIL; + check_network(JJ_OBSERVATION_TRUE, JJ_OBSERVATION_UNKNOWN); + puts("bring-up ESP-IDF adapter host fixtures: PASS"); +} diff --git a/tests/jj_bringup_host_test.c b/tests/jj_bringup_host_test.c new file mode 100644 index 0000000..165b5fa --- /dev/null +++ b/tests/jj_bringup_host_test.c @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Synthetic host fixtures: these are not device observations. +#include "jj_bringup.h" +#include "jj_board.h" +#include "jj_config.h" +#include "dc_prusa.h" +#include +#include +#include +#include + +static dc_prusa_config_t saved = {.host = "printer.local", .port = 80, .api_key = "fixture-secret"}; +static unsigned writes; +static esp_err_t persistence_result; +esp_err_t dc_prusa_get_config(dc_prusa_config_t *out) { *out = saved; return ESP_OK; } +esp_err_t dc_prusa_set_config(const dc_prusa_config_t *value) +{ + ++writes; + if (persistence_result == ESP_OK) saved = *value; + return persistence_result; +} +static cJSON *get(cJSON *root, const char *key) +{ + cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key); + assert(item); + return item; +} +static void config_case(const char *text, esp_err_t expected) +{ + cJSON *values = cJSON_Parse(text); + assert(values); + dc_prusa_config_t before = saved; + unsigned previous_writes = writes; + char message[160] = {0}; + assert(jj_config_apply(values, NULL, message, sizeof(message)) == expected); + assert(!strstr(message, "fixture-secret")); + if (expected == ESP_ERR_INVALID_ARG) { + assert(writes == previous_writes); + assert(memcmp(&saved, &before, sizeof(saved)) == 0); + } + cJSON_Delete(values); +} +static void test_config(void) +{ + config_case("{\"pr_host\":\"printer.local\",\"pr_port\":1}", ESP_OK); + assert(saved.port == 1 && !strcmp(saved.api_key, "fixture-secret")); + config_case("{\"pr_host\":\"192.168.1.2\",\"pr_port\":\"65535\",\"pr_key\":\"\"}", ESP_OK); + assert(saved.port == 65535 && !strcmp(saved.api_key, "fixture-secret")); + const char *bad[] = { + "{}", "{\"pr_host\":null,\"pr_port\":80}", + "{\"pr_host\":\"\",\"pr_port\":80}", + "{\"pr_host\":\"http://printer\",\"pr_port\":80}", + "{\"pr_host\":\"printer/path\",\"pr_port\":80}", + "{\"pr_host\":\"printer#fragment\",\"pr_port\":80}", + "{\"pr_host\":\"printer\\\\path\",\"pr_port\":80}", + "{\"pr_host\":\"printer local\",\"pr_port\":80}", + "{\"pr_host\":\"printer\\n\",\"pr_port\":80}", + "{\"pr_host\":\"printer\"}", + "{\"pr_host\":\"printer\",\"pr_port\":null}", + "{\"pr_host\":\"printer\",\"pr_port\":true}", + "{\"pr_host\":\"printer\",\"pr_port\":0}", + "{\"pr_host\":\"printer\",\"pr_port\":-1}", + "{\"pr_host\":\"printer\",\"pr_port\":65536}", + "{\"pr_host\":\"printer\",\"pr_port\":1.5}", + "{\"pr_host\":\"printer\",\"pr_port\":1e100}", + "{\"pr_host\":\"printer\",\"pr_port\":\"\"}", + "{\"pr_host\":\"printer\",\"pr_port\":\"80x\"}", + "{\"pr_host\":\"printer\",\"pr_port\":\"99999999999999999999999\"}", + "{\"pr_host\":\"printer\",\"pr_port\":80,\"pr_key\":null}", + "{\"pr_host\":\"printer\",\"pr_port\":80,\"pr_key\":false}", + }; + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); ++i) config_case(bad[i], ESP_ERR_INVALID_ARG); + char long_value[66]; memset(long_value, 'a', sizeof(long_value)); long_value[65] = 0; + char input[256]; + snprintf(input, sizeof(input), "{\"pr_host\":\"%s\",\"pr_port\":80}", long_value); + config_case(input, ESP_ERR_INVALID_ARG); + snprintf(input, sizeof(input), "{\"pr_host\":\"printer\",\"pr_port\":80,\"pr_key\":\"%s\"}", long_value); + config_case(input, ESP_ERR_INVALID_ARG); + long_value[63] = 0; + snprintf(input, sizeof(input), "{\"pr_host\":\"%s\",\"pr_port\":80}", long_value); + config_case(input, ESP_OK); + config_case("{\"pr_host\":\"printer\",\"pr_port\":80,\"pr_key\":\"replacement-fixture\"}", ESP_OK); + assert(!strcmp(saved.api_key, "replacement-fixture")); + cJSON *description = jj_config_describe(NULL); + char *serialized = cJSON_PrintUnformatted(description); + assert(serialized && !strstr(serialized, "replacement-fixture")); + cJSON *section = cJSON_GetArrayItem(get(description, "sections"), 0); + cJSON *key = cJSON_GetArrayItem(get(section, "fields"), 2); + assert(cJSON_IsTrue(get(key, "secret"))); + assert(!strcmp(get(key, "value")->valuestring, "")); + free(serialized); cJSON_Delete(description); + persistence_result = ESP_FAIL; + config_case("{\"pr_host\":\"printer\",\"pr_port\":80,\"pr_key\":\"unsaved\"}", ESP_FAIL); + assert(!strcmp(saved.api_key, "replacement-fixture")); + // This product API has no explicit key-clear mutation. Empty retains; + // factory reset delegates to dc_prusa_clear_config in the existing portal. +} +static void test_diagnostics(void) +{ + const jj_board_t *board = jj_board_profile(); + assert(!strcmp(board->profile, "tinys3d_candidate")); + assert(!board->production && !board->heater_available); + assert(board->role_count == 5); + for (size_t i = 0; i < board->role_count; ++i) + assert(board->roles[i].gpio == -1 && board->roles[i].status == JJ_GPIO_UNASSIGNED); + assert(!strcmp(jj_gpio_status_name(JJ_GPIO_PROVISIONAL), "provisional")); + assert(!strcmp(jj_gpio_status_name(JJ_GPIO_BLOCKED), "blocked")); + assert(!strcmp(jj_gpio_status_name(JJ_GPIO_VERIFIED), "verified")); + assert(!strcmp(jj_gpio_status_name((jj_gpio_status_t)99), "unknown")); + jj_boot_observation_t boot = {.idf_version="fixture", .project="fixture", .version="fixture", + .configured_flash_image_mode="dio", .configured_flash_image_frequency="80m", .chip_revision=2}; + jj_runtime_observation_t runtime = {.wifi_state="init", .sampled_at_ms=100, .sample_finished_at_ms=101}; + for (int state = JJ_PSRAM_NOT_ATTEMPTED; state <= JJ_PSRAM_UNKNOWN; ++state) { + boot.psram_state = (jj_psram_state_t)state; + boot.psram_bytes = 8192; // deliberately synthetic; not module capacity evidence + cJSON *root = jj_bringup_json(&boot, &runtime); + cJSON *b = get(root, "boot"), *psram = get(b, "psram"); + assert(cJSON_IsNull(get(b, "detected_flash_bytes"))); + assert(get(b, "chip_revision_minor")->valueint == 2); + const char *expected[] = {"not_attempted", "unavailable", "initialized", "unknown"}; + assert(!strcmp(get(psram, "initialization")->valuestring, expected[state])); + if (state == JJ_PSRAM_INITIALIZED) assert(get(psram, "detected_bytes")->valueint == 8192); + else { + assert(cJSON_IsNull(get(psram, "detected_bytes"))); + assert(!strcmp(get(psram, "physical_presence")->valuestring, "unknown")); + } + cJSON *wifi = get(get(root, "runtime"), "wifi"); + assert(cJSON_IsNull(get(wifi, "sta_associated"))); + assert(cJSON_IsNull(get(wifi, "sta_ipv4_available"))); + assert(!strcmp(get(wifi, "provisioning_completion")->valuestring, "unknown")); + assert(cJSON_IsFalse(get(get(root, "board"), "heater_available"))); + char *text = cJSON_PrintUnformatted(root); + cJSON *roundtrip = cJSON_Parse(text); assert(roundtrip); + free(text); cJSON_Delete(roundtrip); cJSON_Delete(root); + } + boot.flash_known = true; boot.flash_bytes = 8388608; + runtime.sta_associated = JJ_OBSERVATION_TRUE; + runtime.sta_ipv4_available = JJ_OBSERVATION_FALSE; + runtime.dhcp_client_started = JJ_OBSERVATION_TRUE; + cJSON *root = jj_bringup_json(&boot, &runtime); + assert(get(get(root, "boot"), "detected_flash_bytes")->valueint == 8388608); + cJSON *wifi = get(get(root, "runtime"), "wifi"); + assert(cJSON_IsTrue(get(wifi, "sta_associated"))); + assert(cJSON_IsFalse(get(wifi, "sta_ipv4_available"))); + assert(cJSON_IsTrue(get(wifi, "dhcp_client_started"))); + cJSON_Delete(root); + assert(jj_bringup_json(NULL, &runtime) == NULL); +} +int main(void) +{ + test_config(); test_diagnostics(); + puts("bring-up serialization, board and configuration host fixtures: PASS"); +} diff --git a/tests/run_bringup_host_test.sh b/tests/run_bringup_host_test.sh new file mode 100644 index 0000000..0b0ce8b --- /dev/null +++ b/tests/run_bringup_host_test.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +core=${DRAGON_CORE_PATH:?Set DRAGON_CORE_PATH to the pinned dragon-core checkout} +idf=${IDF_PATH:?Set IDF_PATH to the ESP-IDF v5.3 checkout used by firmware CI} +pin=$(sed -n 's/.*version: \([0-9a-f]\{40\}\)/\1/p' "$root/main/idf_component.yml" | sort -u) +[[ $(git -C "$core" rev-parse HEAD) == "$pin" ]] || { echo 'dragon-core test/firmware pin mismatch' >&2; exit 1; } +[[ -z $(git -C "$core" status --porcelain -- components/dc_prusa components/dc_wifi) ]] || { echo 'modified test dependency' >&2; exit 1; } +[[ $(git -C "$idf" rev-parse HEAD) == e0991facf5ecb362af6aac1fae972139eb38d2e4 ]] || { echo 'ESP-IDF test/CI v5.3 mismatch' >&2; exit 1; } +json="$idf/components/json/cJSON" +expected_json=$(git -C "$idf" ls-tree HEAD components/json/cJSON | awk '{print $3}') +[[ $(git -C "$json" rev-parse HEAD) == "$expected_json" ]] || { echo 'cJSON test/IDF pin mismatch' >&2; exit 1; } +[[ -z $(git -C "$json" status --porcelain) ]] || { echo 'modified cJSON test dependency' >&2; exit 1; } +out=$(mktemp) +trap 'rm -f "$out"' EXIT +cc -std=c11 -Wall -Wextra -Werror -fsanitize=undefined -fno-sanitize-recover=undefined \ + -I"$root/tests/stubs" -I"$core/components/dc_prusa/include" -I"$json" \ + -I"$root/components/jj_board/include" -I"$root/components/jj_bringup/include" \ + -I"$root/components/jj_portal/include" \ + "$root/components/jj_board/jj_board.c" "$root/components/jj_bringup/jj_bringup_json.c" \ + "$root/components/jj_portal/jj_config.c" "$root/tests/jj_bringup_host_test.c" \ + "$json/cJSON.c" -lm -o "$out" +"$out" +for config in disabled enabled_no_boot_init enabled_boot_init; do + defs=() + case "$config" in + disabled) defs=(-DCONFIG_SPIRAM=0 -DCONFIG_SPIRAM_BOOT_INIT=0);; + enabled_no_boot_init) defs=(-DCONFIG_SPIRAM=1 -DCONFIG_SPIRAM_BOOT_INIT=0);; + enabled_boot_init) defs=(-DCONFIG_SPIRAM=1 -DCONFIG_SPIRAM_BOOT_INIT=1);; + esac + cc -std=c11 -Wall -Wextra -Werror -fsanitize=undefined -fno-sanitize-recover=undefined \ + "${defs[@]}" -I"$root/tests/stubs/bringup" -I"$root/tests/stubs" \ + -I"$core/components/dc_wifi/include" -I"$json" \ + -I"$root/components/jj_board/include" -I"$root/components/jj_bringup/include" \ + "$root/components/jj_board/jj_board.c" "$root/components/jj_bringup/jj_bringup_json.c" \ + "$root/components/jj_bringup/jj_bringup.c" "$root/tests/jj_bringup_adapter_host_test.c" \ + "$json/cJSON.c" -lm -o "$out" + printf '%s: ' "$config" + "$out" +done diff --git a/tests/stubs/bringup/esp_app_desc.h b/tests/stubs/bringup/esp_app_desc.h new file mode 100644 index 0000000..e949010 --- /dev/null +++ b/tests/stubs/bringup/esp_app_desc.h @@ -0,0 +1,2 @@ +// Host-only fixture. +#include "idf_fixture.h" diff --git a/tests/stubs/bringup/esp_chip_info.h b/tests/stubs/bringup/esp_chip_info.h new file mode 100644 index 0000000..e949010 --- /dev/null +++ b/tests/stubs/bringup/esp_chip_info.h @@ -0,0 +1,2 @@ +// Host-only fixture. +#include "idf_fixture.h" diff --git a/tests/stubs/bringup/esp_flash.h b/tests/stubs/bringup/esp_flash.h new file mode 100644 index 0000000..e949010 --- /dev/null +++ b/tests/stubs/bringup/esp_flash.h @@ -0,0 +1,2 @@ +// Host-only fixture. +#include "idf_fixture.h" diff --git a/tests/stubs/bringup/esp_heap_caps.h b/tests/stubs/bringup/esp_heap_caps.h new file mode 100644 index 0000000..e949010 --- /dev/null +++ b/tests/stubs/bringup/esp_heap_caps.h @@ -0,0 +1,2 @@ +// Host-only fixture. +#include "idf_fixture.h" diff --git a/tests/stubs/bringup/esp_netif.h b/tests/stubs/bringup/esp_netif.h new file mode 100644 index 0000000..e949010 --- /dev/null +++ b/tests/stubs/bringup/esp_netif.h @@ -0,0 +1,2 @@ +// Host-only fixture. +#include "idf_fixture.h" diff --git a/tests/stubs/bringup/esp_psram.h b/tests/stubs/bringup/esp_psram.h new file mode 100644 index 0000000..e949010 --- /dev/null +++ b/tests/stubs/bringup/esp_psram.h @@ -0,0 +1,2 @@ +// Host-only fixture. +#include "idf_fixture.h" diff --git a/tests/stubs/bringup/esp_system.h b/tests/stubs/bringup/esp_system.h new file mode 100644 index 0000000..e949010 --- /dev/null +++ b/tests/stubs/bringup/esp_system.h @@ -0,0 +1,2 @@ +// Host-only fixture. +#include "idf_fixture.h" diff --git a/tests/stubs/bringup/esp_timer.h b/tests/stubs/bringup/esp_timer.h new file mode 100644 index 0000000..e949010 --- /dev/null +++ b/tests/stubs/bringup/esp_timer.h @@ -0,0 +1,2 @@ +// Host-only fixture. +#include "idf_fixture.h" diff --git a/tests/stubs/bringup/esp_wifi.h b/tests/stubs/bringup/esp_wifi.h new file mode 100644 index 0000000..e949010 --- /dev/null +++ b/tests/stubs/bringup/esp_wifi.h @@ -0,0 +1,2 @@ +// Host-only fixture. +#include "idf_fixture.h" diff --git a/tests/stubs/bringup/esp_wifi_types.h b/tests/stubs/bringup/esp_wifi_types.h new file mode 100644 index 0000000..e949010 --- /dev/null +++ b/tests/stubs/bringup/esp_wifi_types.h @@ -0,0 +1,2 @@ +// Host-only fixture. +#include "idf_fixture.h" diff --git a/tests/stubs/bringup/idf_fixture.h b/tests/stubs/bringup/idf_fixture.h new file mode 100644 index 0000000..f12aabb --- /dev/null +++ b/tests/stubs/bringup/idf_fixture.h @@ -0,0 +1,29 @@ +// Host-only API fixtures, never target headers or hardware evidence. +#pragma once +#include "esp_err.h" +#include +#include +#include +typedef struct { int unused; } wifi_ap_record_t; +typedef struct { char project_name[32]; char version[32]; } esp_app_desc_t; +typedef struct { int model; uint16_t revision; uint8_t cores; } esp_chip_info_t; +typedef struct { int unused; } esp_netif_t; +typedef struct { struct { uint32_t addr; } ip; } esp_netif_ip_info_t; +typedef enum { ESP_NETIF_DHCP_INIT, ESP_NETIF_DHCP_STARTED, ESP_NETIF_DHCP_STOPPED } esp_netif_dhcp_status_t; +#define MALLOC_CAP_8BIT (1U << 2) +#define MALLOC_CAP_INTERNAL (1U << 11) +const esp_app_desc_t *esp_app_get_description(void); +void esp_chip_info(esp_chip_info_t *chip); +const char *esp_get_idf_version(void); +int esp_reset_reason(void); +int64_t esp_timer_get_time(void); +esp_err_t esp_flash_get_physical_size(void *chip, uint32_t *size); +bool esp_psram_is_initialized(void); +size_t esp_psram_get_size(void); +size_t heap_caps_get_free_size(uint32_t caps); +size_t heap_caps_get_minimum_free_size(uint32_t caps); +esp_err_t esp_wifi_sta_get_ap_info(wifi_ap_record_t *ap); +esp_netif_t *esp_netif_get_handle_from_ifkey(const char *key); +esp_err_t esp_netif_get_ip_info(esp_netif_t *netif, esp_netif_ip_info_t *ip); +bool esp_netif_is_netif_up(esp_netif_t *netif); +esp_err_t esp_netif_dhcpc_get_status(esp_netif_t *netif, esp_netif_dhcp_status_t *status); diff --git a/tests/stubs/bringup/sdkconfig.h b/tests/stubs/bringup/sdkconfig.h new file mode 100644 index 0000000..981fbd1 --- /dev/null +++ b/tests/stubs/bringup/sdkconfig.h @@ -0,0 +1,3 @@ +// Host-only configuration, not a PSRAM characterization build profile. +#define CONFIG_ESPTOOLPY_FLASHMODE "dio" +#define CONFIG_ESPTOOLPY_FLASHFREQ "80m" diff --git a/tests/stubs/esp_err.h b/tests/stubs/esp_err.h new file mode 100644 index 0000000..a10abb9 --- /dev/null +++ b/tests/stubs/esp_err.h @@ -0,0 +1,8 @@ +// Host-only ESP-IDF error fixture; never used by firmware. +#pragma once +typedef int esp_err_t; +#define ESP_OK 0 +#define ESP_FAIL -1 +#define ESP_ERR_INVALID_ARG 0x102 +#define ESP_ERR_INVALID_STATE 0x103 +#define ESP_ERR_WIFI_NOT_CONNECT 0x300f From 406ea4559c6d60f7b833a7dc3b70056d69586167 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 15 Sep 2026 05:42:39 -0700 Subject: [PATCH 2/2] docs: record TinyS3D reverse-voltage observation --- docs/hardware/tinys3d-characterization.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/hardware/tinys3d-characterization.md b/docs/hardware/tinys3d-characterization.md index 8cbe816..ffb7b5f 100644 --- a/docs/hardware/tinys3d-characterization.md +++ b/docs/hardware/tinys3d-characterization.md @@ -63,6 +63,22 @@ open the console before the boot burst. No firmware console change was needed. | USB powered; DMM from header `5V` to GND | 4.871 V | Source VBUS was not independently measured; cable/contact drop and onboard-path drop cannot be separated | | USB-C disconnected; approximately 4.87 V applied to header `5V`; DMM from USB-C VBUS to GND | Started at 0.235 V and continued rising | Test stopped before settling; no defined VBUS load, elapsed-time trace, or final voltage recorded | +### Additional user-reported reverse-voltage observation + +During the unloaded/high-impedance reverse-voltage/isolation experiment, Dan +reported **approximately 0.173 V initially → approximately 0.600 V after +approximately 5 minutes, still slowly rising**. This is a supplied physical +observation, not a measurement performed during the scaffolding session. +The earlier 0.235 V entry above is retained as prior recorded evidence; the +relationship between that reading and this later report has not been established. +No measurement topology, meter impedance, resistor value or other missing setup +detail is inferred for the new observation. + +**VBUS isolation/backfeed remains unresolved.** This reading establishes neither +direct 5 V/VBUS coupling, confirmed isolation, nor safe simultaneous power. +A weak leakage/capacitive-charging path is one plausible explanation; the circuit +mechanism is not established. **Loaded resistor retesting remains required.** + A high-impedance DMM can display voltage from leakage into an otherwise unloaded node. Meter input impedance was not measured (approximately 10 MΩ was only a typical-value assumption). The partial rising reading does not establish the @@ -146,3 +162,7 @@ Through-hole carrier mounting (direct headers or sockets) remains undecided. - [ ] Confirm PSRAM presence/size by runtime query or chip marking. - [ ] Verify antenna-select default/IO38 behavior and usable GPIO/ADC mapping. - [ ] Establish mounting method and verify the candidate footprint at 1:1 scale. + +Read-only firmware capture instructions and field semantics are in the +[bring-up diagnostics guide](tinys3d-bringup-diagnostics.md). This scaffolding +is not additional physical validation of the candidate.