diff --git a/.github/workflows/firmware-build.yml b/.github/workflows/firmware-build.yml index ade2e5a..158b9ca 100644 --- a/.github/workflows/firmware-build.yml +++ b/.github/workflows/firmware-build.yml @@ -32,6 +32,9 @@ jobs: - name: Test control authority state machine run: ./tests/run_authority_host_test.sh + - name: Test PID controller lifecycle and staged authorization + run: ./tests/run_control_host_test.sh + - name: Test browser control client run: node --test tests/control-client.test.cjs @@ -47,6 +50,9 @@ jobs: - name: Check control ordering, locking, and fault policy run: sh tests/check_control_safety_contract.sh + - name: Check controller/safety boundary + run: sh tests/check_controller_contract.sh + - name: Enforce production actuation allowlist run: sh tests/check_actuation_allowlist.sh diff --git a/components/jj_authority/CMakeLists.txt b/components/jj_authority/CMakeLists.txt index d2cd51e..be4d723 100644 --- a/components/jj_authority/CMakeLists.txt +++ b/components/jj_authority/CMakeLists.txt @@ -1,6 +1,6 @@ idf_component_register( SRCS "jj_authority.c" INCLUDE_DIRS "include" - REQUIRES jj_interlock + REQUIRES jj_interlock jj_control ) target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/components/jj_authority/include/jj_authority.h b/components/jj_authority/include/jj_authority.h index 36239d8..6ecf46f 100644 --- a/components/jj_authority/include/jj_authority.h +++ b/components/jj_authority/include/jj_authority.h @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #pragma once +#include "jj_control.h" #include "jj_interlock.h" #include #include @@ -119,8 +120,13 @@ void jj_authority_snapshot( void jj_authority_apply_to_inputs( const jj_control_snapshot_t *authority, jj_inputs_t *inputs); +/* + * One ordered control step under the authority lock: authority -> interlock + * Stage 1 -> controller -> interlock Stage 2. A NULL controller fails cold. + */ jj_outputs_t jj_authority_control_step( jj_authority_t *state, + jj_controller_t *controller, const jj_inputs_t *authoritative_inputs, uint64_t now_ms, jj_control_snapshot_t *out); diff --git a/components/jj_authority/jj_authority.c b/components/jj_authority/jj_authority.c index 4aa4aed..d80ea79 100644 --- a/components/jj_authority/jj_authority.c +++ b/components/jj_authority/jj_authority.c @@ -443,6 +443,7 @@ void jj_authority_apply_to_inputs( jj_outputs_t jj_authority_control_step( jj_authority_t *state, + jj_controller_t *controller, const jj_inputs_t *authoritative_inputs, uint64_t now_ms, jj_control_snapshot_t *out) @@ -454,7 +455,16 @@ jj_outputs_t jj_authority_control_step( AUTHORITY_LOCK(); (void)lease_expire_unlocked(state, now_ms); jj_authority_apply_to_inputs(&state->state, &input); - const jj_outputs_t output = jj_interlock_step(state->interlock, &input); + /* + * Both interlock stages and the controller step run under this one lock, + * so no authority mutation or revocation can interleave between them. + */ + const jj_eligibility_t eligibility = + jj_interlock_evaluate(state->interlock, &input); + const jj_controller_result_t controller_result = + jj_controller_step(controller, &eligibility, &input); + const jj_outputs_t output = jj_interlock_authorize( + state->interlock, controller ? &controller_result.report : NULL); snapshot_unlocked(state, now_ms, out); AUTHORITY_UNLOCK(); return output; diff --git a/components/jj_control/CMakeLists.txt b/components/jj_control/CMakeLists.txt new file mode 100644 index 0000000..e986b41 --- /dev/null +++ b/components/jj_control/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "jj_control.c" + INCLUDE_DIRS "include" + REQUIRES jj_interlock dc_pid +) +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/components/jj_control/include/jj_control.h b/components/jj_control/include/jj_control.h new file mode 100644 index 0000000..cf79bd6 --- /dev/null +++ b/components/jj_control/include/jj_control.h @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "dc_pid.h" +#include "jj_interlock.h" +#include + +/* + * Jump Jet product control policy around dc_pid. + * + * Owns setpoint resolution, dc_pid state/config lifecycle, integrate + * decisions, and the raw requested duty. Owns no safety eligibility, + * authorization, fault handling, limiting, or actuation: it consumes the + * interlock's Stage-1 jj_eligibility_t and reports back through + * jj_controller_report_t for Stage 2. + */ + +typedef struct { + dc_pid_config_t pid; + float sample_period_s; /* dt passed to dc_pid_step() each control step */ +} jj_controller_config_t; + +typedef struct { + jj_controller_config_t config; + dc_pid_state_t pid; +} jj_controller_t; + +typedef struct { + jj_controller_report_t report; /* the only part Stage 2 consumes */ + /* Advanced diagnostics; not primary HMI. Zeroed unless a step ran. */ + dc_pid_result_t pid; + bool stepped; + bool integrating; + float setpoint_c; /* NAN when no setpoint applies */ +} jj_controller_result_t; + +/* + * PROVISIONAL, UNCHARACTERIZED. Zero gains: a step produces a valid 0 % request. + * Output/integral bounds are the 0..100 % duty unit, not tuning. The sample + * period mirrors the existing 250 ms control-task cadence and is not an + * approved controller interval. Replace only from Phase 3 plant evidence. + */ +jj_controller_config_t jj_controller_provisional_config(void); + +void jj_controller_init(jj_controller_t *controller, + const jj_controller_config_t *config); + +jj_controller_result_t jj_controller_step(jj_controller_t *controller, + const jj_eligibility_t *eligibility, + const jj_inputs_t *input); diff --git a/components/jj_control/jj_control.c b/components/jj_control/jj_control.c new file mode 100644 index 0000000..d00ab7c --- /dev/null +++ b/components/jj_control/jj_control.c @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "jj_control.h" +#include +#include + +/* + * This unit runs inside jj_authority's critical section: bounded work only. + * It must not test fan proof, faults, printer state, authority, sensor status, + * or target validity. Those predicates live only in jj_interlock. + */ + +jj_controller_config_t jj_controller_provisional_config(void) +{ + return (jj_controller_config_t){ + .pid = { + .kp = 0.0f, .ki = 0.0f, .kd = 0.0f, /* PROVISIONAL: untuned */ + .derivative_alpha = 0.0f, + .output_min = 0.0f, .output_max = 100.0f, + .integral_min = 0.0f, .integral_max = 100.0f, + }, + .sample_period_s = 0.25f, /* PROVISIONAL: inherited task cadence */ + }; +} + +void jj_controller_init(jj_controller_t *controller, + const jj_controller_config_t *config) +{ + if (!controller) return; + memset(controller, 0, sizeof(*controller)); + if (config) controller->config = *config; + dc_pid_reset(&controller->pid); +} + +/* Setpoint resolution is product policy owned here, from mode alone. */ +static bool resolve_setpoint(const jj_inputs_t *input, float *setpoint_c) +{ + switch (input->mode) { + case JJ_MODE_MANUAL: + *setpoint_c = input->manual_target_c; + return true; + case JJ_MODE_OFF: + case JJ_MODE_AUTOMATIC: /* no Phase-4 chamber-target policy exists */ + return false; + } + return false; +} + +jj_controller_result_t jj_controller_step(jj_controller_t *controller, + const jj_eligibility_t *eligibility, + const jj_inputs_t *input) +{ + jj_controller_result_t result = { + .report = {.state = JJ_CONTROLLER_IDLE, .requested_duty_pct = 0.0f}, + .setpoint_c = NAN, + }; + if (!controller || !eligibility || !input) { + result.report.state = JJ_CONTROLLER_INVALID; + return result; + } + result.report.eligibility_sequence = eligibility->sequence; + + bool known = false, integrate = false; + switch (eligibility->disposition) { /* exhaustive: no default */ + case JJ_DISPOSITION_SUSPEND: + /* IDLE for as long as suspension lasts; never resume on stale state. */ + dc_pid_reset(&controller->pid); + return result; + case JJ_DISPOSITION_HOLD: + known = true; /* preserve state, freeze integral */ + break; + case JJ_DISPOSITION_RUN: + known = true; + integrate = true; + break; + } + if (!known) { + result.report.state = JJ_CONTROLLER_INVALID; + return result; + } + + float setpoint_c; + if (!resolve_setpoint(input, &setpoint_c)) { + dc_pid_reset(&controller->pid); + return result; /* IDLE: no applicable setpoint */ + } + result.setpoint_c = setpoint_c; + + /* + * PROVISIONAL: no special handling of target changes. dc_pid's + * derivative-on-measurement avoids setpoint kick; whether the integral + * should be preserved or reinitialized across a target change is open + * pending Phase 3. dc_pid output is used unmodified: no target-crossing + * override exists here. + */ + result.stepped = true; + if (dc_pid_step(&controller->pid, &controller->config.pid, setpoint_c, + input->chamber.temperature_c, + controller->config.sample_period_s, integrate, + &result.pid)) { + result.report.state = JJ_CONTROLLER_VALID; + result.report.requested_duty_pct = result.pid.output; + result.integrating = integrate; + } else { + result.report.state = JJ_CONTROLLER_INVALID; + result.report.requested_duty_pct = 0.0f; + } + return result; +} diff --git a/components/jj_interlock/include/jj_interlock.h b/components/jj_interlock/include/jj_interlock.h index dd76397..410fa83 100644 --- a/components/jj_interlock/include/jj_interlock.h +++ b/components/jj_interlock/include/jj_interlock.h @@ -52,7 +52,35 @@ typedef enum { JJ_BLOCK_MANUAL_TARGET_INVALID, JJ_BLOCK_FAN_PROOF_PENDING, JJ_BLOCK_INVALID_MODE, JJ_BLOCK_CONTROL_NO_AUTHORITY, JJ_BLOCK_STATE_REFRESH_REQUIRED, JJ_BLOCK_AUTO_AUTHORITY_UNAVAILABLE, + /* Stage-2 denials: produced only by jj_interlock_authorize(). */ + JJ_BLOCK_CONTROLLER_INVALID, JJ_BLOCK_CONTROL_SEQUENCE_INVALID, + JJ_BLOCK_REASON_COUNT, /* sentinel; not a reason */ } jj_block_reason_t; +_Static_assert(JJ_BLOCK_REASON_COUNT <= 32, "inhibit_mask is 32 bits wide"); + +/* + * Controller lifecycle. IDLE is an ordinary state (no step was attempted); + * INVALID means a step was expected but the controller could not produce a + * valid request. Zero values are the safe defaults. + */ +typedef enum { + JJ_CONTROLLER_IDLE = 0, + JJ_CONTROLLER_VALID, + JJ_CONTROLLER_INVALID, +} jj_controller_state_t; + +/* + * What the controller may do this cycle. Numeric order is precedence: + * SUSPEND (most restrictive) < HOLD < RUN, so aggregation takes the minimum. + * SUSPEND: do not step; controller IDLE; controller state reset. + * HOLD: step with integration frozen; state preserved; heat not allowed. + * RUN: step with integration enabled; heat may be authorized in Stage 2. + */ +typedef enum { + JJ_DISPOSITION_SUSPEND = 0, + JJ_DISPOSITION_HOLD, + JJ_DISPOSITION_RUN, +} jj_controller_disposition_t; typedef struct { jj_sensor_status_t status; float temperature_c; } jj_sensor_sample_t; typedef struct { @@ -81,8 +109,25 @@ typedef struct { jj_printer_sample_t printer; jj_fan_proof_t fan_proof; } jj_inputs_t; +/* + * Stage-1 result. Produced only by jj_interlock_evaluate(); the controller + * reads it but Stage 2 never trusts a caller's copy of it. + */ +typedef struct { + uint32_t sequence; /* interlock-issued evaluation token */ + jj_block_reason_t dominant_reason; /* diagnostic/HMI priority only */ + uint32_t inhibit_mask; /* 1u << reason for each inhibit found */ + jj_controller_disposition_t disposition; /* aggregate over all inhibits */ +} jj_eligibility_t; + +/* The only controller data Stage 2 consumes. Treated as untrusted input. */ +typedef struct { + uint32_t eligibility_sequence; /* copied from the jj_eligibility_t used */ + jj_controller_state_t state; + float requested_duty_pct; /* raw controller request; 0 unless VALID */ +} jj_controller_report_t; + typedef struct { - bool heater_requested; uint8_t fan_percent; float effective_target_c; bool thermal_management_required; @@ -91,12 +136,33 @@ typedef struct { jj_thermal_state_t thermal_state; jj_fault_t fault; jj_block_reason_t block_reason; + jj_controller_disposition_t controller_disposition; + jj_controller_state_t controller_state; + float requested_duty_pct; /* raw controller request, never safety-modified */ + bool heater_authorized; /* safety authorization, independent of magnitude */ + float allowed_duty_pct; /* 0 unless heater_authorized */ } jj_outputs_t; -typedef struct { jj_fault_t fault_latched; jj_outputs_t last_output; } jj_interlock_t; +typedef struct { + jj_fault_t fault_latched; + jj_outputs_t last_output; + /* Stage-1 evaluation awaiting its single Stage-2 consumption. */ + bool pending_valid; + uint32_t next_sequence; + jj_eligibility_t pending; + jj_outputs_t pending_output; +} jj_interlock_t; jj_inputs_t jj_inputs_safe_defaults(void); void jj_interlock_init(jj_interlock_t *state); -jj_outputs_t jj_interlock_step(jj_interlock_t *state, const jj_inputs_t *input); +/* + * Staged evaluation. Both stages run inside one control step. Stage 1 publishes + * a de-authorized snapshot immediately; Stage 2 consumes that exact evaluation + * once. Revocation, fault clear, or a newer Stage 1 invalidates it. + */ +jj_eligibility_t jj_interlock_evaluate(jj_interlock_t *state, const jj_inputs_t *input); +jj_outputs_t jj_interlock_authorize(jj_interlock_t *state, + const jj_controller_report_t *report); +jj_controller_disposition_t jj_block_reason_disposition(jj_block_reason_t reason); bool jj_interlock_clear_fault(jj_interlock_t *state, const jj_inputs_t *input); void jj_interlock_remove_remote_authorization( jj_interlock_t *state, @@ -110,3 +176,5 @@ const char *jj_block_reason_str(jj_block_reason_t reason); const char *jj_control_authority_str(jj_control_authority_t authority); const char *jj_control_inhibit_str(jj_control_inhibit_t inhibit); const char *jj_thermal_state_str(jj_thermal_state_t state); +const char *jj_controller_state_str(jj_controller_state_t state); +const char *jj_controller_disposition_str(jj_controller_disposition_t disposition); diff --git a/components/jj_interlock/jj_interlock.c b/components/jj_interlock/jj_interlock.c index df83071..24a2d19 100644 --- a/components/jj_interlock/jj_interlock.c +++ b/components/jj_interlock/jj_interlock.c @@ -2,6 +2,7 @@ #include "jj_interlock.h" #include #include +#include #include #ifdef ESP_PLATFORM @@ -55,6 +56,110 @@ void jj_interlock_init(jj_interlock_t *state) STATE_UNLOCK(); } +/* + * Controller disposition for every block reason. Exhaustive with no default: + * adding a jj_block_reason_t without deciding its disposition fails the + * -Werror build (-Wswitch). Only an explicitly approved transient inhibit may + * HOLD; everything else SUSPENDs (idle + reset). + */ +jj_controller_disposition_t jj_block_reason_disposition(jj_block_reason_t reason) +{ + switch (reason) { + case JJ_BLOCK_NONE: + return JJ_DISPOSITION_RUN; + case JJ_BLOCK_FAN_PROOF_PENDING: + /* Approved Category A: transient authorization loss. */ + return JJ_DISPOSITION_HOLD; + case JJ_BLOCK_OFF: + case JJ_BLOCK_NOT_COMMISSIONED: /* approved: suspend + reset */ + case JJ_BLOCK_FAULT_LATCHED: /* suspended for the whole latch */ + case JJ_BLOCK_INVALID_MODE: + case JJ_BLOCK_MANUAL_TARGET_INVALID: /* approved: no valid objective */ + case JJ_BLOCK_PRINTER_UNAVAILABLE: /* AUTOMATIC: no Phase-4 setpoint */ + case JJ_BLOCK_PRINTER_NOT_PRINTING: + case JJ_BLOCK_AUTO_POLICY_UNAVAILABLE: + case JJ_BLOCK_AUTO_AUTHORITY_UNAVAILABLE: + case JJ_BLOCK_CONTROL_NO_AUTHORITY: + case JJ_BLOCK_STATE_REFRESH_REQUIRED: + case JJ_BLOCK_CONTROLLER_INVALID: + case JJ_BLOCK_CONTROL_SEQUENCE_INVALID: + case JJ_BLOCK_REASON_COUNT: + return JJ_DISPOSITION_SUSPEND; + } + return JJ_DISPOSITION_SUSPEND; /* out-of-range value */ +} + +typedef struct { + jj_block_reason_t dominant; + uint32_t mask; + jj_controller_disposition_t disposition; +} inhibit_accumulator_t; + +/* + * Record one applicable inhibit. The first recorded reason is the dominant + * diagnostic reason (unchanged PR #7 priority). Disposition is the minimum over + * every recorded reason, so it does not depend on check order. Returns true + * once SUSPEND is reached: nothing later can change the disposition. + */ +static bool record(inhibit_accumulator_t *acc, jj_block_reason_t reason) +{ + if (acc->dominant == JJ_BLOCK_NONE) acc->dominant = reason; + acc->mask |= UINT32_C(1) << (uint32_t)reason; + const jj_controller_disposition_t disposition = + jj_block_reason_disposition(reason); + if (disposition < acc->disposition) acc->disposition = disposition; + return acc->disposition == JJ_DISPOSITION_SUSPEND; +} + +/* + * The single implementation of heat-eligibility predicates. Check order and + * fault-latching side effects are unchanged from PR #7; only the early + * returns now continue past non-SUSPEND inhibits. + */ +static void collect_inhibits(jj_interlock_t *state, const jj_inputs_t *input, + inhibit_accumulator_t *acc) +{ + if (!input->commissioned && record(acc, JJ_BLOCK_NOT_COMMISSIONED)) return; + if (!sensors_ok(input)) state->fault_latched = JJ_FAULT_SENSOR; + if (input->overtemperature_detected) + state->fault_latched = JJ_FAULT_OVERTEMPERATURE; + if (state->fault_latched != JJ_FAULT_NONE && + record(acc, JJ_BLOCK_FAULT_LATCHED)) return; + if (input->mode == JJ_MODE_OFF && record(acc, JJ_BLOCK_OFF)) return; + if (input->mode != JJ_MODE_MANUAL && input->mode != JJ_MODE_AUTOMATIC && + record(acc, JJ_BLOCK_INVALID_MODE)) return; + if (input->fan_proof == JJ_FAN_PROOF_FAILED || + (input->fan_proof != JJ_FAN_PROOF_UNAVAILABLE && + input->fan_proof != JJ_FAN_PROOF_PENDING && + input->fan_proof != JJ_FAN_PROOF_PROVEN)) { + state->fault_latched = JJ_FAULT_FAN; + if (record(acc, JJ_BLOCK_FAULT_LATCHED)) return; + } + if (input->fan_proof != JJ_FAN_PROOF_PROVEN && + record(acc, JJ_BLOCK_FAN_PROOF_PENDING)) return; + + if (input->mode == JJ_MODE_AUTOMATIC) { + if (input->active_authority != JJ_AUTHORITY_AUTOMATIC && + record(acc, JJ_BLOCK_AUTO_AUTHORITY_UNAVAILABLE)) return; + if (!input->printer.online && + record(acc, JJ_BLOCK_PRINTER_UNAVAILABLE)) return; + if (!input->printer.printing && + record(acc, JJ_BLOCK_PRINTER_NOT_PRINTING)) return; + /* dc_prusa owns its 15 s freshness decision. No second timer lives here. + * Exact bed-target mapping is intentionally undefined, so AUTO is cold. */ + (void)record(acc, JJ_BLOCK_AUTO_POLICY_UNAVAILABLE); + return; + } + if ((input->active_authority == JJ_AUTHORITY_REACQUIRING || + input->control_inhibit == JJ_CONTROL_INHIBIT_STATE_REFRESH_REQUIRED) && + record(acc, JJ_BLOCK_STATE_REFRESH_REQUIRED)) return; + if ((input->active_authority != JJ_AUTHORITY_REMOTE || + !input->manual_demand_authorized) && + record(acc, JJ_BLOCK_CONTROL_NO_AUTHORITY)) return; + if (!manual_target_valid(input->manual_target_c)) + (void)record(acc, JJ_BLOCK_MANUAL_TARGET_INVALID); +} + static jj_outputs_t blocked(jj_interlock_t *state, jj_block_reason_t reason, const jj_inputs_t *input) { @@ -73,8 +178,7 @@ static jj_outputs_t blocked(jj_interlock_t *state, jj_block_reason_t reason, const bool thermal_management = input->cooldown_required || input->fault_requires_thermal_management || state->fault_latched == JJ_FAULT_FAN || reason == JJ_BLOCK_FAN_PROOF_PENDING; - jj_outputs_t output = { - .heater_requested = false, + return (jj_outputs_t){ .fan_percent = thermal_management ? 100 : 0, .effective_target_c = 0.0f, .thermal_management_required = thermal_management, @@ -84,75 +188,135 @@ static jj_outputs_t blocked(jj_interlock_t *state, jj_block_reason_t reason, .fault = state->fault_latched, .block_reason = reason, }; - state->last_output = output; +} + +/* Strip any authorization from an output; optionally name the denial. */ +static jj_outputs_t deauthorized(jj_outputs_t output, jj_block_reason_t reason) +{ + output.heater_authorized = false; + output.allowed_duty_pct = 0.0f; + output.effective_target_c = 0.0f; + output.thermal_state = output.thermal_management_required + ? JJ_THERMAL_COOLDOWN : JJ_THERMAL_IDLE; + if (output.block_reason == JJ_BLOCK_NONE) output.block_reason = reason; return output; } -static jj_outputs_t step_unlocked(jj_interlock_t *state, const jj_inputs_t *input) +static jj_eligibility_t evaluate_unlocked(jj_interlock_t *state, + const jj_inputs_t *input) { - if (!input->commissioned) - return blocked(state, JJ_BLOCK_NOT_COMMISSIONED, input); - if (!sensors_ok(input)) state->fault_latched = JJ_FAULT_SENSOR; - if (input->overtemperature_detected) - state->fault_latched = JJ_FAULT_OVERTEMPERATURE; - if (state->fault_latched != JJ_FAULT_NONE) - return blocked(state, JJ_BLOCK_FAULT_LATCHED, input); - if (input->mode == JJ_MODE_OFF) - return blocked(state, JJ_BLOCK_OFF, input); - if (input->mode != JJ_MODE_MANUAL && input->mode != JJ_MODE_AUTOMATIC) - return blocked(state, JJ_BLOCK_INVALID_MODE, input); - if (input->fan_proof == JJ_FAN_PROOF_FAILED || - (input->fan_proof != JJ_FAN_PROOF_UNAVAILABLE && - input->fan_proof != JJ_FAN_PROOF_PENDING && - input->fan_proof != JJ_FAN_PROOF_PROVEN)) { - state->fault_latched = JJ_FAULT_FAN; - return blocked(state, JJ_BLOCK_FAULT_LATCHED, input); - } - if (input->fan_proof != JJ_FAN_PROOF_PROVEN) - return blocked(state, JJ_BLOCK_FAN_PROOF_PENDING, input); + inhibit_accumulator_t acc = { + .dominant = JJ_BLOCK_NONE, + .mask = 0, + .disposition = JJ_DISPOSITION_RUN, + }; + collect_inhibits(state, input, &acc); - const float target = input->manual_target_c; - if (input->mode == JJ_MODE_AUTOMATIC) { - if (input->active_authority != JJ_AUTHORITY_AUTOMATIC) - return blocked(state, JJ_BLOCK_AUTO_AUTHORITY_UNAVAILABLE, input); - if (!input->printer.online) - return blocked(state, JJ_BLOCK_PRINTER_UNAVAILABLE, input); - if (!input->printer.printing) - return blocked(state, JJ_BLOCK_PRINTER_NOT_PRINTING, input); - /* dc_prusa owns its 15 s freshness decision. No second timer lives here. - * Exact bed-target mapping is intentionally undefined, so AUTO is cold. */ - return blocked(state, JJ_BLOCK_AUTO_POLICY_UNAVAILABLE, input); + jj_outputs_t output; + if (acc.dominant == JJ_BLOCK_NONE) { + /* Eligible form; Stage 2 publishes it only if it authorizes. */ + output = (jj_outputs_t){ + .fan_percent = 100, + .effective_target_c = input->manual_target_c, + .thermal_management_required = true, + .active_authority = JJ_AUTHORITY_REMOTE, + .control_inhibit = JJ_CONTROL_INHIBIT_NONE, + .thermal_state = JJ_THERMAL_HEATING, + .fault = JJ_FAULT_NONE, + .block_reason = JJ_BLOCK_NONE, + }; + } else { + output = blocked(state, acc.dominant, input); } - if (input->active_authority == JJ_AUTHORITY_REACQUIRING || - input->control_inhibit == JJ_CONTROL_INHIBIT_STATE_REFRESH_REQUIRED) - return blocked(state, JJ_BLOCK_STATE_REFRESH_REQUIRED, input); - if (input->active_authority != JJ_AUTHORITY_REMOTE || - !input->manual_demand_authorized) - return blocked(state, JJ_BLOCK_CONTROL_NO_AUTHORITY, input); - if (!manual_target_valid(target)) - return blocked(state, JJ_BLOCK_MANUAL_TARGET_INVALID, input); - jj_outputs_t output = { - .heater_requested = true, - .fan_percent = 100, - .effective_target_c = target, - .thermal_management_required = true, - .active_authority = JJ_AUTHORITY_REMOTE, - .control_inhibit = JJ_CONTROL_INHIBIT_NONE, - .thermal_state = JJ_THERMAL_HEATING, - .fault = JJ_FAULT_NONE, - .block_reason = JJ_BLOCK_NONE, + output.controller_disposition = acc.disposition; + + const jj_eligibility_t eligibility = { + .sequence = ++state->next_sequence, + .dominant_reason = acc.dominant, + .inhibit_mask = acc.mask, + .disposition = acc.disposition, }; + state->pending = eligibility; + state->pending_output = output; + state->pending_valid = true; + /* Never leave an earlier authorization visible while Stage 2 is pending. */ + state->last_output = deauthorized(output, JJ_BLOCK_NONE); + return eligibility; +} + +jj_eligibility_t jj_interlock_evaluate(jj_interlock_t *state, const jj_inputs_t *input) +{ + if (!state || !input) + return (jj_eligibility_t){ + .dominant_reason = JJ_BLOCK_FAULT_LATCHED, + .disposition = JJ_DISPOSITION_SUSPEND, + }; + STATE_LOCK(); + const jj_eligibility_t eligibility = evaluate_unlocked(state, input); + STATE_UNLOCK(); + return eligibility; +} + +static bool report_state_known(jj_controller_state_t value) +{ + switch (value) { + case JJ_CONTROLLER_IDLE: + case JJ_CONTROLLER_VALID: + case JJ_CONTROLLER_INVALID: + return true; + } + return false; +} + +static jj_outputs_t authorize_unlocked(jj_interlock_t *state, + const jj_controller_report_t *report) +{ + if (!state->pending_valid || !report || + report->eligibility_sequence != state->pending.sequence) { + /* No matching Stage 1 for this report: fail cold, consume nothing. */ + state->pending_valid = false; + state->last_output = deauthorized(state->last_output, + JJ_BLOCK_CONTROL_SEQUENCE_INVALID); + return state->last_output; + } + /* Use the interlock-retained evaluation, exactly once. */ + const jj_eligibility_t eligibility = state->pending; + jj_outputs_t output = state->pending_output; + state->pending_valid = false; + + jj_controller_state_t controller = report_state_known(report->state) + ? report->state : JJ_CONTROLLER_INVALID; + const float request = report->requested_duty_pct; + if (controller == JJ_CONTROLLER_VALID && + !(isfinite(request) && request >= 0.0f && request <= 100.0f)) + controller = JJ_CONTROLLER_INVALID; + if (eligibility.disposition == JJ_DISPOSITION_SUSPEND && + controller != JJ_CONTROLLER_IDLE) + controller = JJ_CONTROLLER_INVALID; /* stepped while suspended */ + output.controller_state = controller; + /* Raw request is reported unmodified; safety only gates allowed duty. */ + output.requested_duty_pct = controller == JJ_CONTROLLER_VALID ? request : 0.0f; + + if (eligibility.disposition == JJ_DISPOSITION_RUN && + controller == JJ_CONTROLLER_VALID) { + output.heater_authorized = true; + /* No non-safety product limiter exists yet (ownership unresolved). */ + output.allowed_duty_pct = output.requested_duty_pct; + } else { + output = deauthorized(output, JJ_BLOCK_CONTROLLER_INVALID); + } state->last_output = output; return output; } -jj_outputs_t jj_interlock_step(jj_interlock_t *state, const jj_inputs_t *input) +jj_outputs_t jj_interlock_authorize(jj_interlock_t *state, + const jj_controller_report_t *report) { - if (!state || !input) + if (!state) return (jj_outputs_t){.fault = JJ_FAULT_SENSOR, .block_reason = JJ_BLOCK_FAULT_LATCHED}; STATE_LOCK(); - jj_outputs_t output = step_unlocked(state, input); + const jj_outputs_t output = authorize_unlocked(state, report); STATE_UNLOCK(); return output; } @@ -194,6 +358,7 @@ static bool clear_fault_unlocked(jj_interlock_t *state, const jj_inputs_t *input return false; } state->fault_latched = JJ_FAULT_NONE; + state->pending_valid = false; state->last_output = (jj_outputs_t){.block_reason = JJ_BLOCK_OFF}; return true; } @@ -215,7 +380,10 @@ void jj_interlock_remove_remote_authorization( { if (!state) return; STATE_LOCK(); - state->last_output.heater_requested = false; + /* Revocation also voids any Stage-1 result not yet authorized. */ + state->pending_valid = false; + state->last_output.heater_authorized = false; + state->last_output.allowed_duty_pct = 0.0f; state->last_output.effective_target_c = 0.0f; state->last_output.active_authority = authority; state->last_output.control_inhibit = inhibit; @@ -295,6 +463,8 @@ const char *jj_block_reason_str(jj_block_reason_t reason) case JJ_BLOCK_CONTROL_NO_AUTHORITY: return "control_no_authority"; case JJ_BLOCK_STATE_REFRESH_REQUIRED: return "state_refresh_required"; case JJ_BLOCK_AUTO_AUTHORITY_UNAVAILABLE: return "automatic_authority_unavailable"; + case JJ_BLOCK_CONTROLLER_INVALID: return "controller_invalid"; + case JJ_BLOCK_CONTROL_SEQUENCE_INVALID: return "control_sequence_invalid"; default: return "unknown"; } } @@ -330,3 +500,23 @@ const char *jj_thermal_state_str(jj_thermal_state_t state) default: return "unknown"; } } + +const char *jj_controller_state_str(jj_controller_state_t state) +{ + switch (state) { + case JJ_CONTROLLER_IDLE: return "idle"; + case JJ_CONTROLLER_VALID: return "valid"; + case JJ_CONTROLLER_INVALID: return "invalid"; + default: return "unknown"; + } +} + +const char *jj_controller_disposition_str(jj_controller_disposition_t disposition) +{ + switch (disposition) { + case JJ_DISPOSITION_SUSPEND: return "suspend"; + case JJ_DISPOSITION_HOLD: return "hold"; + case JJ_DISPOSITION_RUN: return "run"; + default: return "unknown"; + } +} diff --git a/components/jj_portal/jj_portal.c b/components/jj_portal/jj_portal.c index 6720077..ee189eb 100644 --- a/components/jj_portal/jj_portal.c +++ b/components/jj_portal/jj_portal.c @@ -239,7 +239,13 @@ static cJSON *control_state_json( const bool raw_request = authority->mode == JJ_MODE_MANUAL && authority->manual_demand_authorized; cJSON_AddBoolToObject(heater, "requested", raw_request); - cJSON_AddBoolToObject(heater, "allowed", output->heater_requested); + cJSON_AddStringToObject(heater, "control_loop", + jj_controller_state_str(output->controller_state)); + cJSON_AddNumberToObject(heater, "requested_duty_percent", + output->requested_duty_pct); + cJSON_AddBoolToObject(heater, "allowed", output->heater_authorized); + cJSON_AddNumberToObject(heater, "allowed_duty_percent", + output->allowed_duty_pct); cJSON_AddBoolToObject(heater, "delivered", false); cJSON_AddNumberToObject(heater, "delivered_percent", 0); cJSON_AddBoolToObject(heater, "available", false); @@ -586,7 +592,7 @@ static bool authorize(httpd_req_t *req, void *ctx) static esp_err_t thermal_state_guard(char *message, size_t message_size) { const jj_outputs_t output = jj_interlock_snapshot(s_interlock); - if (!output.heater_requested && !output.thermal_management_required) + if (!output.heater_authorized && !output.thermal_management_required) return ESP_OK; snprintf(message, message_size, "OTA is unavailable while heating or active thermal management is required."); diff --git a/components/jj_portal/web/control-client.js b/components/jj_portal/web/control-client.js index 32a45eb..f800f8d 100644 --- a/components/jj_portal/web/control-client.js +++ b/components/jj_portal/web/control-client.js @@ -48,10 +48,40 @@ control_no_authority: "Control inhibited: remote controller disconnected", state_refresh_required: "Control inhibited: authoritative state refresh required", automatic_authority_unavailable: "Heating inhibited: Automatic controller unavailable", + controller_invalid: "Heating inhibited: controller could not produce a valid request", + control_sequence_invalid: "Heating inhibited: control evaluation was out of sequence", }; return messages[code] || "Heating inhibited: see advanced diagnostics"; } + const CONTROL_LOOP_LABELS = { + idle: {text: "Idle", className: ""}, + valid: {text: "Running", className: ""}, + invalid: {text: "Invalid: controller failure", className: "fault"}, + }; + + function dutyText(value) { + return typeof value === "number" && Number.isFinite(value) + ? value.toFixed(1) + " %" : "—"; + } + + /* + * Primary heater presentation. Controller state is shown on its own so an + * INVALID controller stays visible whatever the dominant constraint is. + */ + function heaterPresentation(heater) { + const h = heater || {}; + const loop = Object.prototype.hasOwnProperty.call(CONTROL_LOOP_LABELS, h.control_loop) + ? CONTROL_LOOP_LABELS[h.control_loop] + : {text: "Unknown", className: "inhibit"}; + return { + controllerText: loop.text, + controllerClass: loop.className, + requestedDuty: dutyText(h.requested_duty_percent), + allowedDuty: dutyText(h.allowed_duty_percent), + }; + } + function mutationErrorMessage(code) { if (code === "state_revision_conflict") return "Authoritative state changed. Refresh and verify the current result; the earlier request may have completed."; @@ -199,6 +229,7 @@ CONTROL_LOSS_ERRORS, controllerLabel, constraintMessage, + heaterPresentation, mutationErrorMessage, }; }); diff --git a/components/jj_portal/web/control.html b/components/jj_portal/web/control.html index a27f006..ce0c752 100644 --- a/components/jj_portal/web/control.html +++ b/components/jj_portal/web/control.html @@ -32,8 +32,11 @@

Off · None

Configured target
—
Effective target
—
+
Controller
—
Heater request
—
+
Requested duty
—
Heater allowed
—
+
Allowed duty
—
Heater delivered
—
Thermal handling
—
Health / fault
—
@@ -83,8 +86,13 @@

Advanced control details

state.dominant_constraint); $("configured").textContent = state.configured_target_c + " °C"; $("effective").textContent = state.effective_target_c == null ? "—" : state.effective_target_c + " °C"; + const heater = JumpJetControl.heaterPresentation(state.heater); + $("controller").textContent = heater.controllerText; + $("controller").className = heater.controllerClass; $("requested").textContent = state.heater.requested ? "On" : "Off"; + $("requested_duty").textContent = heater.requestedDuty; $("allowed").textContent = state.heater.allowed ? "On" : "Off"; + $("allowed_duty").textContent = heater.allowedDuty; $("delivered").textContent = state.heater.delivered ? "On" : "Off"; $("thermal").textContent = cap(state.thermal_state); $("health").textContent = state.safety.fault_latched ? "Fault: " + state.safety.fault : state.safety.health; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3e4d3e6..541a6e0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -81,8 +81,46 @@ interlock code never acquires authority. Authority critical sections contain only bounded state checks/copies, the eight-entry cache scan, and short interlock operations—no logging, formatted I/O, allocation, or blocking calls. +## PID-family control loop + +Normal heater control is PID-family closed-loop control; hysteretic bang-bang +exists only as a future Phase 3 validation comparator. `dc_pid` (pinned +dragon-core) provides controller mathematics only. `jj_control` owns setpoint +resolution, `dc_pid` state/config lifecycle, integration decisions, and the raw +request. `jj_interlock` remains the sole safety authority. + +Inside the one ordered control step, under the authority lock: + +1. `jj_interlock_evaluate()` (Stage 1) runs the single set of eligibility + predicates and returns a dominant block reason for diagnostics plus a + controller disposition aggregated over every applicable inhibit + (SUSPEND > HOLD > RUN). Diagnostic priority never decides disposition. Stage 1 + immediately publishes a de-authorized snapshot and retains the evaluation + under a sequence token. +2. `jj_controller_step()` consumes that result. SUSPEND: controller IDLE, state + reset. HOLD: step with integration frozen, state preserved. RUN: step with + integration. It never re-derives eligibility from `jj_inputs_t`. +3. `jj_interlock_authorize()` (Stage 2) consumes the retained evaluation exactly + once, matched by token. It authorizes only RUN with a VALID controller and a + finite 0–100 % request; `allowed_duty_pct` is then the raw request. Any other + combination, a stale/replayed/unknown token, or a revocation or fault clear + between the stages fails cold. + +Controller state is IDLE (no step attempted; ordinary), VALID, or INVALID (step +expected but rejected; reported as `controller_invalid`). `requested_duty_pct` +is the unmodified `dc_pid` output and 0 unless VALID. No product code compares +temperature to target; there is no target-crossing override. + +Only fan-proof-pending HOLDs. Every other inhibit, including NOT_COMMISSIONED +and MANUAL_TARGET_INVALID, SUSPENDs; the classification is an exhaustive switch, +so a new block reason does not compile until classified. Gains, sample interval, +integral/output limits, target-change integral handling, and any product duty +limiter are provisional pending Phase 3 plant evidence. The shipped +configuration has zero gains and the production image is uncommissioned, so it +never produces a heat request. + ## OTA invariant -The portal rejects OTA when heat is requested or active thermal management is +The portal rejects OTA when heat is authorized or active thermal management is required. The same authoritative guard is rerun immediately before boot selection, then project identity is checked. Browser/API controls cannot override interlocks. diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index d1217bf..845749d 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,6 +1,6 @@ idf_component_register( SRCS "app_main.c" INCLUDE_DIRS "." - REQUIRES jj_authority jj_identity jj_interlock jj_portal dc_evlog dc_prusa dc_wifi nvs_flash app_update + REQUIRES jj_authority jj_control 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 2937dfa..21e01fe 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_authority.h" +#include "jj_control.h" #include "jj_identity.h" #include "jj_interlock.h" #include "jj_portal.h" @@ -21,6 +22,7 @@ static const char *TAG = JJ_IDENTITY_PRODUCT_ID; static jj_interlock_t s_interlock; static jj_authority_t s_authority; +static jj_controller_t s_controller; static bool printer_is_printing(const char *state) { @@ -48,7 +50,7 @@ static void control_task(void *arg) */ const uint64_t now_ms = (uint64_t)esp_timer_get_time() / 1000U; const jj_outputs_t output = jj_authority_control_step( - &s_authority, &input, now_ms, NULL); + &s_authority, &s_controller, &input, now_ms, NULL); if (!startup_reported) { startup_reported = true; xTaskNotifyGive(startup_task); @@ -75,6 +77,9 @@ void app_main(void) ESP_ERROR_CHECK(err); jj_interlock_init(&s_interlock); jj_authority_init(&s_authority, &s_interlock, JJ_REMOTE_LEASE_TTL_MS); + const jj_controller_config_t controller_config = + jj_controller_provisional_config(); + jj_controller_init(&s_controller, &controller_config); BaseType_t created = xTaskCreate(control_task, "jj_control", 4096, xTaskGetCurrentTaskHandle(), 8, NULL); ESP_ERROR_CHECK(created == pdPASS ? ESP_OK : ESP_ERR_NO_MEM); @@ -91,7 +96,8 @@ void app_main(void) ESP_ERROR_CHECK(ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(2000)) > 0 ? ESP_OK : ESP_ERR_TIMEOUT); const jj_outputs_t startup_output = jj_interlock_snapshot(&s_interlock); - ESP_ERROR_CHECK(!startup_output.heater_requested && + ESP_ERROR_CHECK(!startup_output.heater_authorized && + startup_output.allowed_duty_pct == 0.0f && startup_output.effective_target_c == 0.0f ? ESP_OK : ESP_ERR_INVALID_STATE); const esp_partition_t *running = esp_ota_get_running_partition(); diff --git a/main/idf_component.yml b/main/idf_component.yml index 125352e..ab53c5e 100644 --- a/main/idf_component.yml +++ b/main/idf_component.yml @@ -3,6 +3,10 @@ dependencies: git: https://github.com/justinh-rahb/dragon-core.git path: components/dc_evlog version: 4e041d864763d468a50e9649807827dd83dd54bc + dc_pid: + git: https://github.com/justinh-rahb/dragon-core.git + path: components/dc_pid + version: 4e041d864763d468a50e9649807827dd83dd54bc dc_prusa: git: https://github.com/justinh-rahb/dragon-core.git path: components/dc_prusa diff --git a/tests/check_control_safety_contract.sh b/tests/check_control_safety_contract.sh index 55b12d4..8b57a5b 100755 --- a/tests/check_control_safety_contract.sh +++ b/tests/check_control_safety_contract.sh @@ -15,13 +15,13 @@ if grep -q 'jj_authority_snapshot' "$app"; then echo "control task must not snapshot authority before cached inputs" >&2 exit 1 fi -if grep -q 'jj_interlock_step' "$app" "$portal"; then +if grep -Eq 'jj_interlock_(evaluate|authorize)|jj_controller_step' "$app" "$portal"; then echo "ordinary production interlock evaluation must remain control-task owned" >&2 exit 1 fi if rg -n '(ESP_LOG[A-Z]*|\b(printf|snprintf|vfprintf|malloc|calloc|realloc|free|xSemaphoreTake|vTaskDelay)[[:space:]]*\(|esp_http)' \ - "$authority" >/dev/null; then + "$authority" "$root/components/jj_control/jj_control.c" >/dev/null; then echo "authority critical-section unit contains forbidden unbounded work" >&2 exit 1 fi diff --git a/tests/check_controller_contract.sh b/tests/check_controller_contract.sh new file mode 100755 index 0000000..44a5d1b --- /dev/null +++ b/tests/check_controller_contract.sh @@ -0,0 +1,52 @@ +#!/bin/sh +# Static guards for the PID-family controller/safety boundary. +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +control="$root/components/jj_control/jj_control.c" +control_h="$root/components/jj_control/include/jj_control.h" +interlock="$root/components/jj_interlock/jj_interlock.c" +manifest="$root/main/idf_component.yml" +command -v rg >/dev/null 2>&1 || { echo "controller contract check requires rg" >&2; exit 1; } + +fail() { echo "$1" >&2; exit 1; } + +# 1. jj_control holds no safety/eligibility predicates; jj_interlock owns them. +# Comments are stripped first so prose cannot mask or trip the check. +strip() { cc -fpreprocessed -dD -E -P "$1" 2>/dev/null; } +for file in "$control" "$control_h"; do + if strip "$file" | rg -n '\b(fan_proof|fault\w*|printer|commissioned|overtemperature\w*|active_authority|manual_demand_authorized|control_inhibit|cooldown\w*|status|JJ_SENSOR_\w+|JJ_FAULT_\w+|JJ_MANUAL_TARGET_(MIN|MAX)_C|JJ_BLOCK_\w+|JJ_AUTHORITY_\w+)\b'; then + fail "jj_control must not implement safety/eligibility predicates ($file)" + fi +done + +# 2. No product-level target-crossing override anywhere in firmware sources. +if rg -n --pcre2 '(temperature_c|measurement)[^;{}]*[<>]=?[^;{}]*(setpoint|target)|(setpoint|target)[^;{}]*[<>]=?[^;{}]*(temperature_c|measurement)' \ + "$root/components" "$root/main" -g '*.c' -g '*.h'; then + fail "temperature-vs-target comparisons belong only inside dc_pid" +fi + +# 3. The raw dc_pid output is the request, unmodified. +grep -q 'result.report.requested_duty_pct = result.pid.output;' "$control" || + fail "requested duty must be the unmodified dc_pid output" +[ "$(grep -c 'dc_pid_step(' "$control")" -eq 1 ] || + fail "jj_control must have exactly one dc_pid_step call site" + +# 4. The retired boolean must not return. +if rg -n 'heater_requested' "$root/components" "$root/main" "$root/tests" -g '!check_controller_contract.sh'; then + fail "heater_requested was replaced by heater_authorized/allowed_duty_pct" +fi + +# 5. Stage 2 authorizes only RUN + VALID, from interlock-retained state. +grep -q 'eligibility.disposition == JJ_DISPOSITION_RUN &&' "$interlock" || + fail "Stage 2 must require RUN disposition" +grep -q 'const jj_eligibility_t eligibility = state->pending;' "$interlock" || + fail "Stage 2 must use the interlock-retained Stage-1 result" + +# 6. dc_pid is pinned to the same dragon-core commit as every other dc_* dep. +pins=$(awk '/^ version:/ { print $2 }' "$manifest" | sort -u) +[ "$(printf '%s\n' "$pins" | wc -l)" -eq 1 ] || + fail "all dragon-core components must share one pinned commit" +grep -q '^ dc_pid:' "$manifest" || fail "dc_pid dependency missing" + +echo "controller/safety boundary contract check: PASS" diff --git a/tests/check_ota_contract.sh b/tests/check_ota_contract.sh index d350cd4..2cb7009 100644 --- a/tests/check_ota_contract.sh +++ b/tests/check_ota_contract.sh @@ -9,7 +9,7 @@ interlock="$root/components/jj_interlock/jj_interlock.c" grep -q 'guard_operation = guard_operation' "$portal" grep -q 'validate_image = validate_image' "$portal" grep -q 'esp_err_t err = thermal_state_guard(message, message_size);' "$portal" -grep -q '!output.heater_requested && !output.thermal_management_required' "$portal" +grep -q '!output.heater_authorized && !output.thermal_management_required' "$portal" grep -q 'strcmp(image->project_name, JJ_IDENTITY_PRODUCT_ID)' "$portal" grep -q '"commissioned", false' "$portal" grep -q '"heater_available", false' "$portal" diff --git a/tests/control-client.test.cjs b/tests/control-client.test.cjs index 6552739..04682ec 100644 --- a/tests/control-client.test.cjs +++ b/tests/control-client.test.cjs @@ -10,6 +10,7 @@ const { ControlError, controllerLabel, constraintMessage, + heaterPresentation, mutationErrorMessage, } = require("../components/jj_portal/web/control-client.js"); @@ -183,3 +184,76 @@ test("primary constraints are operator-readable while unknown codes stay generic assert.equal(constraintMessage("future_internal_code"), "Heating inhibited: see advanced diagnostics"); }); + +test("controller-state constraints have operator-readable messages", () => { + assert.equal(constraintMessage("controller_invalid"), + "Heating inhibited: controller could not produce a valid request"); + assert.equal(constraintMessage("control_sequence_invalid"), + "Heating inhibited: control evaluation was out of sequence"); +}); + +test("idle controller renders as an ordinary inactive state", () => { + const view = heaterPresentation({control_loop: "idle", + requested_duty_percent: 0, allowed_duty_percent: 0}); + assert.equal(view.controllerText, "Idle"); + assert.equal(view.controllerClass, ""); + assert.equal(view.requestedDuty, "0.0 %"); + assert.equal(view.allowedDuty, "0.0 %"); +}); + +test("valid controller renders normally with requested and allowed duty", () => { + const view = heaterPresentation({control_loop: "valid", allowed: true, + requested_duty_percent: 37.5, allowed_duty_percent: 37.5}); + assert.equal(view.controllerText, "Running"); + assert.equal(view.controllerClass, ""); + assert.equal(view.requestedDuty, "37.5 %"); + assert.equal(view.allowedDuty, "37.5 %"); +}); + +test("invalid controller renders as a distinct failure", () => { + const view = heaterPresentation({control_loop: "invalid", allowed: false, + requested_duty_percent: 0, allowed_duty_percent: 0}); + assert.equal(view.controllerText, "Invalid: controller failure"); + assert.equal(view.controllerClass, "fault"); + assert.notEqual(view.controllerClass, heaterPresentation({control_loop: "idle"}).controllerClass); +}); + +test("HOLD with an invalid controller still exposes INVALID", () => { + const holdInvalid = {dominant_constraint: "fan_proof_pending", + heater: {control_loop: "invalid", allowed: false, + requested_duty_percent: 0, allowed_duty_percent: 0}}; + assert.equal(constraintMessage(holdInvalid.dominant_constraint), + "Heating inhibited: airflow is not proven"); + const view = heaterPresentation(holdInvalid.heater); + assert.equal(view.controllerText, "Invalid: controller failure"); + assert.equal(view.controllerClass, "fault"); +}); + +test("HOLD request stays visible while allowed duty is zero", () => { + const view = heaterPresentation({control_loop: "valid", allowed: false, + requested_duty_percent: 42, allowed_duty_percent: 0}); + assert.equal(view.requestedDuty, "42.0 %"); + assert.equal(view.allowedDuty, "0.0 %"); +}); + +test("missing or unknown controller data never renders as healthy", () => { + for (const heater of [undefined, {}, {control_loop: "future"}, + {control_loop: "valid", requested_duty_percent: NaN}]) { + const view = heaterPresentation(heater); + if (!heater || heater.control_loop !== "valid") { + assert.equal(view.controllerText, "Unknown"); + assert.equal(view.controllerClass, "inhibit"); + } + if (!heater || !Number.isFinite(heater.requested_duty_percent)) + assert.equal(view.requestedDuty, "—"); + } +}); + +test("primary panel wires controller state and both duties", () => { + const page = fs.readFileSync(path.join( + __dirname, "../components/jj_portal/web/control.html"), "utf8"); + for (const id of ["controller", "requested_duty", "allowed_duty"]) + assert.match(page, new RegExp(`
`)); + assert.match(page, /JumpJetControl\.heaterPresentation\(state\.heater\)/); + assert.match(page, /\$\("controller"\)\.className = heater\.controllerClass/); +}); diff --git a/tests/fetch_dc_pid.sh b/tests/fetch_dc_pid.sh new file mode 100755 index 0000000..128ea28 --- /dev/null +++ b/tests/fetch_dc_pid.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Print a directory holding dc_pid sources at the exact commit pinned in +# main/idf_component.yml, so host tests compile the dependency the firmware +# builds against. No second pin exists. +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +manifest="$root/main/idf_component.yml" +field() { + awk -v key="$1" ' + /^ dc_pid:[[:space:]]*$/ { inside=1; next } + inside && /^ [^ ]/ { exit } + inside && $1 == key":" { print $2; exit } + ' "$manifest" +} +url=$(field git); path=$(field path); commit=$(field version) +[ -n "$url" ] && [ -n "$path" ] && [ -n "$commit" ] || { + echo "dc_pid dependency missing from $manifest" >&2; exit 1; } +case "$commit" in + *[!0-9a-f]*|"") echo "dc_pid pin must be a full commit SHA" >&2; exit 1 ;; +esac +[ "${#commit}" -eq 40 ] || { echo "dc_pid pin must be a full commit SHA" >&2; exit 1; } +dest="$root/build/host-deps/dc_pid-$commit" +if [ ! -f "$dest/dc_pid.c" ] || [ ! -f "$dest/include/dc_pid.h" ]; then + tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT + git -C "$tmp" init -q + git -C "$tmp" fetch -q --depth 1 "$url" "$commit" + [ "$(git -C "$tmp" rev-parse FETCH_HEAD)" = "$commit" ] || { + echo "fetched dc_pid commit does not match pin" >&2; exit 1; } + git -C "$tmp" checkout -q FETCH_HEAD -- "$path" + mkdir -p "$dest" + cp -R "$tmp/$path/." "$dest/" +fi +printf '%s\n' "$dest" diff --git a/tests/jj_authority_host_test.c b/tests/jj_authority_host_test.c index 8a65fdc..6aa4cdf 100644 --- a/tests/jj_authority_host_test.c +++ b/tests/jj_authority_host_test.c @@ -76,13 +76,25 @@ static jj_control_snapshot_t refresh( return snapshot; } +static jj_controller_t s_controller; + +/* Staged interlock cycle with the provisional (zero-gain) controller. */ +static jj_outputs_t interlock_cycle(jj_interlock_t *interlock, + const jj_inputs_t *inputs) +{ + const jj_eligibility_t eligibility = jj_interlock_evaluate(interlock, inputs); + const jj_controller_result_t result = + jj_controller_step(&s_controller, &eligibility, inputs); + return jj_interlock_authorize(interlock, &result.report); +} + static jj_outputs_t evaluate( jj_interlock_t *interlock, const jj_control_snapshot_t *authority, jj_inputs_t inputs) { jj_authority_apply_to_inputs(authority, &inputs); - return jj_interlock_step(interlock, &inputs); + return interlock_cycle(interlock, &inputs); } static void test_manual_expiry_fails_cold_without_fault_and_keeps_cooldown(void) @@ -97,11 +109,11 @@ static void test_manual_expiry_fails_cold_without_fault_and_keeps_cooldown(void) jj_control_snapshot_t manual = mutate( &authority, &interlock, &lease, JJ_MUTATION_MANUAL, 45.0f, "manual-1", &inputs, 1); - CHECK(evaluate(&interlock, &manual, inputs).heater_requested); + CHECK(evaluate(&interlock, &manual, inputs).heater_authorized); CHECK(jj_authority_tick(&authority, 1000)); jj_outputs_t output = jj_interlock_snapshot(&interlock); - CHECK(!output.heater_requested); + CHECK(!output.heater_authorized); CHECK(output.thermal_state == JJ_THERMAL_COOLDOWN); jj_control_snapshot_t expired; jj_authority_snapshot(&authority, 1000, &expired); @@ -115,7 +127,7 @@ static void test_manual_expiry_fails_cold_without_fault_and_keeps_cooldown(void) "remote_lease_expired") == 0); inputs.cooldown_required = true; output = evaluate(&interlock, &expired, inputs); - CHECK(!output.heater_requested); + CHECK(!output.heater_authorized); CHECK(output.fault == JJ_FAULT_NONE); CHECK(output.control_inhibit == JJ_CONTROL_INHIBIT_NO_AUTHORITY); CHECK(output.thermal_state == JJ_THERMAL_COOLDOWN); @@ -141,7 +153,7 @@ static void test_reacquire_requires_refresh_and_new_explicit_request(void) CHECK(reacquired.control_inhibit == JJ_CONTROL_INHIBIT_STATE_REFRESH_REQUIRED); CHECK(!reacquired.manual_demand_authorized); - CHECK(!evaluate(&interlock, &reacquired, inputs).heater_requested); + CHECK(!evaluate(&interlock, &reacquired, inputs).heater_authorized); jj_control_mutation_t premature = { .generation = reacquired.generation, @@ -165,7 +177,7 @@ static void test_reacquire_requires_refresh_and_new_explicit_request(void) "manual-b", &inputs, 1005); CHECK(resumed.active_authority == JJ_AUTHORITY_REMOTE); CHECK(resumed.manual_demand_authorized); - CHECK(evaluate(&interlock, &resumed, inputs).heater_requested); + CHECK(evaluate(&interlock, &resumed, inputs).heater_authorized); } static void test_stale_generation_and_revision_are_atomic(void) @@ -209,7 +221,7 @@ static void test_explicit_takeover_revokes_old_remote_demand(void) first = refresh(&authority, &first, 0); first = mutate(&authority, &interlock, &first, JJ_MUTATION_MANUAL, 45.0f, "manual-a", &inputs, 1); - CHECK(evaluate(&interlock, &first, inputs).heater_requested); + CHECK(evaluate(&interlock, &first, inputs).heater_authorized); jj_control_acquire_t denied = {.takeover = false}; snprintf(denied.lease_id, sizeof denied.lease_id, "%s", LEASE_B); @@ -228,8 +240,8 @@ static void test_explicit_takeover_revokes_old_remote_demand(void) JJ_CONTROL_LOSS_EXPLICIT_TAKEOVER); CHECK(strcmp(jj_control_loss_reason_str(takeover.last_control_loss_reason), "explicit_takeover") == 0); - CHECK(!jj_interlock_snapshot(&interlock).heater_requested); - CHECK(!evaluate(&interlock, &takeover, inputs).heater_requested); + CHECK(!jj_interlock_snapshot(&interlock).heater_authorized); + CHECK(!evaluate(&interlock, &takeover, inputs).heater_authorized); jj_control_snapshot_t heartbeat; CHECK(jj_authority_heartbeat(&authority, LEASE_A, first.generation, 4, &heartbeat) == JJ_CONTROL_GENERATION_STALE); @@ -250,7 +262,7 @@ static void test_automatic_survives_browser_loss_but_eligibility_fails_cold(void &authority, &interlock, &lease, JJ_MUTATION_AUTOMATIC, 0.0f, "auto-a", &inputs, 1); jj_outputs_t output = evaluate(&interlock, &automatic, inputs); - CHECK(!output.heater_requested); + CHECK(!output.heater_authorized); CHECK(output.block_reason == JJ_BLOCK_AUTO_POLICY_UNAVAILABLE); CHECK(jj_authority_tick(&authority, 1000)); @@ -259,29 +271,29 @@ static void test_automatic_survives_browser_loss_but_eligibility_fails_cold(void CHECK(expired.mode == JJ_MODE_AUTOMATIC); CHECK(expired.active_authority == JJ_AUTHORITY_AUTOMATIC); output = evaluate(&interlock, &expired, inputs); - CHECK(!output.heater_requested); + CHECK(!output.heater_authorized); CHECK(output.block_reason == JJ_BLOCK_AUTO_POLICY_UNAVAILABLE); inputs.printer.online = false; output = evaluate(&interlock, &expired, inputs); - CHECK(!output.heater_requested); + CHECK(!output.heater_authorized); CHECK(output.fault == JJ_FAULT_NONE); CHECK(output.block_reason == JJ_BLOCK_PRINTER_UNAVAILABLE); CHECK(output.control_inhibit == JJ_CONTROL_INHIBIT_NOT_ELIGIBLE); inputs.printer.online = true; inputs.printer.printing = false; output = evaluate(&interlock, &expired, inputs); - CHECK(!output.heater_requested); + CHECK(!output.heater_authorized); CHECK(output.block_reason == JJ_BLOCK_PRINTER_NOT_PRINTING); inputs.printer.printing = true; inputs.automatic_target_available = false; output = evaluate(&interlock, &expired, inputs); - CHECK(!output.heater_requested); + CHECK(!output.heater_authorized); CHECK(output.block_reason == JJ_BLOCK_AUTO_POLICY_UNAVAILABLE); inputs.automatic_target_available = true; inputs.fan_proof = JJ_FAN_PROOF_PENDING; output = evaluate(&interlock, &expired, inputs); - CHECK(!output.heater_requested); + CHECK(!output.heater_authorized); CHECK(output.block_reason == JJ_BLOCK_FAN_PROOF_PENDING); } @@ -322,7 +334,7 @@ static void test_faults_are_not_cleared_by_authority_and_client_cannot_bypass(vo jj_inputs_t inputs = nominal_inputs(); jj_inputs_t faulted = inputs; faulted.chamber.status = JJ_SENSOR_OPEN; - CHECK(jj_interlock_step(&interlock, &faulted).fault == JJ_FAULT_SENSOR); + CHECK(interlock_cycle(&interlock, &faulted).fault == JJ_FAULT_SENSOR); jj_control_snapshot_t lease = acquire(&authority, LEASE_A, "phone", false, 0); lease = refresh(&authority, &lease, 0); @@ -421,7 +433,7 @@ static void test_retry_after_expiry_never_replays_historical_manual_demand(void) CHECK(!retry.lease_active); CHECK(!retry.manual_demand_authorized); CHECK(retry.active_authority == JJ_AUTHORITY_NONE); - CHECK(!evaluate(&interlock, &retry, inputs).heater_requested); + CHECK(!evaluate(&interlock, &retry, inputs).heater_authorized); } static void test_directional_authority_sampling(void) @@ -438,25 +450,25 @@ static void test_directional_authority_sampling(void) "directional-manual", &cached_prusa_inputs, 1); /* Grants do not rewrite the current interlock decision. */ - CHECK(!jj_interlock_snapshot(&interlock).heater_requested); + CHECK(!jj_interlock_snapshot(&interlock).heater_authorized); CHECK(jj_authority_control_step( - &authority, &cached_prusa_inputs, 2, &manual).heater_requested); + &authority, &s_controller, &cached_prusa_inputs, 2, &manual).heater_authorized); /* A takeover after the cached Prusa read revokes that decision now. */ jj_control_snapshot_t takeover = acquire( &authority, LEASE_B, "phone-b", true, 3); CHECK(!takeover.manual_demand_authorized); - CHECK(!jj_interlock_snapshot(&interlock).heater_requested); + CHECK(!jj_interlock_snapshot(&interlock).heater_authorized); CHECK(!jj_authority_control_step( - &authority, &cached_prusa_inputs, 3, NULL).heater_requested); + &authority, &s_controller, &cached_prusa_inputs, 3, NULL).heater_authorized); takeover = refresh(&authority, &takeover, 4); jj_control_snapshot_t second_manual = mutate( &authority, &interlock, &takeover, JJ_MUTATION_MANUAL, 45.0f, "directional-second-manual", &cached_prusa_inputs, 5); - CHECK(!jj_interlock_snapshot(&interlock).heater_requested); + CHECK(!jj_interlock_snapshot(&interlock).heater_authorized); CHECK(jj_authority_control_step( - &authority, &cached_prusa_inputs, 5, NULL).heater_requested); + &authority, &s_controller, &cached_prusa_inputs, 5, NULL).heater_authorized); jj_control_snapshot_t reacquiring = acquire( &authority, LEASE_A, "phone-b", false, 6); @@ -465,19 +477,19 @@ static void test_directional_authority_sampling(void) CHECK(strcmp(jj_control_loss_reason_str( reacquiring.last_control_loss_reason), "remote_reacquired") == 0); - CHECK(!jj_interlock_snapshot(&interlock).heater_requested); + CHECK(!jj_interlock_snapshot(&interlock).heater_authorized); reacquiring = refresh(&authority, &reacquiring, 7); second_manual = mutate( &authority, &interlock, &reacquiring, JJ_MUTATION_MANUAL, 45.0f, "directional-third-manual", &cached_prusa_inputs, 8); CHECK(jj_authority_control_step( - &authority, &cached_prusa_inputs, 8, NULL).heater_requested); + &authority, &s_controller, &cached_prusa_inputs, 8, NULL).heater_authorized); jj_control_snapshot_t off = mutate( &authority, &interlock, &second_manual, JJ_MUTATION_OFF, 0.0f, "directional-off", &cached_prusa_inputs, 9); CHECK(off.mode == JJ_MODE_OFF); - CHECK(!jj_interlock_snapshot(&interlock).heater_requested); + CHECK(!jj_interlock_snapshot(&interlock).heater_authorized); } static void test_evicted_retry_reports_revision_conflict_with_current_state(void) @@ -558,8 +570,32 @@ static void test_remote_clear_obeys_fault_class_policy(void) CHECK(interlock.fault_latched == JJ_FAULT_UNCONTROLLED_RISE); } + +static void test_control_step_without_controller_fails_cold(void) +{ + jj_authority_t authority; + jj_interlock_t interlock; + jj_authority_init(&authority, &interlock, 1000); + jj_interlock_init(&interlock); + jj_inputs_t inputs = nominal_inputs(); + jj_control_snapshot_t lease = acquire(&authority, LEASE_A, "phone-a", false, 0); + lease = refresh(&authority, &lease, 0); + (void)mutate(&authority, &interlock, &lease, JJ_MUTATION_MANUAL, 45.0f, + "null-controller", &inputs, 1); + CHECK(jj_authority_control_step(&authority, &s_controller, &inputs, 2, NULL) + .heater_authorized); + const jj_outputs_t output = + jj_authority_control_step(&authority, NULL, &inputs, 3, NULL); + CHECK(!output.heater_authorized); + CHECK(output.allowed_duty_pct == 0.0f); + CHECK(output.block_reason == JJ_BLOCK_CONTROL_SEQUENCE_INVALID); + CHECK(!jj_interlock_snapshot(&interlock).heater_authorized); +} + int main(void) { + const jj_controller_config_t config = jj_controller_provisional_config(); + jj_controller_init(&s_controller, &config); test_manual_expiry_fails_cold_without_fault_and_keeps_cooldown(); test_reacquire_requires_refresh_and_new_explicit_request(); test_stale_generation_and_revision_are_atomic(); @@ -572,6 +608,7 @@ int main(void) test_directional_authority_sampling(); test_evicted_retry_reports_revision_conflict_with_current_state(); test_remote_clear_obeys_fault_class_policy(); + test_control_step_without_controller_fails_cold(); puts("jj_authority_host_test: PASS"); return 0; } diff --git a/tests/jj_control_host_test.c b/tests/jj_control_host_test.c new file mode 100644 index 0000000..e8a8498 --- /dev/null +++ b/tests/jj_control_host_test.c @@ -0,0 +1,414 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "jj_control.h" +#include "jj_interlock.h" +#include +#include +#include +#include + +#define CHECK(condition) do { if (!(condition)) { \ + fprintf(stderr, "%s:%d: CHECK failed: %s\n", __FILE__, __LINE__, #condition); \ + exit(1); } } while (0) + +/* + * TEST FIXTURE ONLY. Non-zero gains exist so lifecycle behavior is observable. + * They are not tuning and say nothing about the Jump Jet thermal plant. + */ +static jj_controller_config_t fixture_config(void) +{ + jj_controller_config_t config = jj_controller_provisional_config(); + config.pid.kp = 4.0f; + config.pid.ki = 0.5f; + return config; +} + +static jj_inputs_t manual_inputs(float chamber_c) +{ + return (jj_inputs_t){ + .commissioned = true, + .mode = JJ_MODE_MANUAL, + .manual_target_c = JJ_MANUAL_TARGET_DEFAULT_C, + .active_authority = JJ_AUTHORITY_REMOTE, + .manual_demand_authorized = true, + .chamber = {.status = JJ_SENSOR_OK, .temperature_c = chamber_c}, + .outlet = {.status = JJ_SENSOR_OK, .temperature_c = 35.0f}, + .case_sensor = {.status = JJ_SENSOR_OK, .temperature_c = 36.0f}, + .printer = {.online = true, .printing = true, .bed_target_c = 100.0f}, + .fan_proof = JJ_FAN_PROOF_PROVEN, + }; +} + +typedef struct { + jj_controller_result_t controller; + jj_outputs_t output; +} cycle_t; + +/* The production ordering: Stage 1 -> controller -> Stage 2. */ +static cycle_t run_cycle(jj_interlock_t *interlock, jj_controller_t *controller, + const jj_inputs_t *input) +{ + cycle_t c; + const jj_eligibility_t eligibility = jj_interlock_evaluate(interlock, input); + c.controller = jj_controller_step(controller, &eligibility, input); + c.output = jj_interlock_authorize(interlock, &c.controller.report); + return c; +} + +static void check_cold(const cycle_t *c) +{ + CHECK(!c->output.heater_authorized); + CHECK(c->output.allowed_duty_pct == 0.0f); +} + +static void check_idle(const cycle_t *c) +{ + CHECK(!c->controller.stepped); + CHECK(c->controller.report.state == JJ_CONTROLLER_IDLE); + CHECK(c->output.controller_state == JJ_CONTROLLER_IDLE); + CHECK(c->output.requested_duty_pct == 0.0f); + check_cold(c); +} + +static void setup(jj_interlock_t *interlock, jj_controller_t *controller, + jj_controller_config_t config) +{ + jj_interlock_init(interlock); + jj_controller_init(controller, &config); +} + +static void test_boot_and_off_are_idle(void) +{ + jj_interlock_t interlock; + jj_controller_t controller; + setup(&interlock, &controller, fixture_config()); + + const jj_inputs_t boot = jj_inputs_safe_defaults(); + cycle_t c = run_cycle(&interlock, &controller, &boot); + check_idle(&c); + CHECK(c.output.block_reason == JJ_BLOCK_NOT_COMMISSIONED); + + jj_inputs_t off = manual_inputs(30.0f); + off.mode = JJ_MODE_OFF; + c = run_cycle(&interlock, &controller, &off); + check_idle(&c); + CHECK(c.output.block_reason == JJ_BLOCK_OFF); + /* IDLE is ordinary: not a fault, not the controller-invalid reason. */ + CHECK(c.output.fault == JJ_FAULT_NONE); + CHECK(c.output.block_reason != JJ_BLOCK_CONTROLLER_INVALID); +} + +static void test_off_to_manual_steps_and_authorizes(void) +{ + jj_interlock_t interlock; + jj_controller_t controller; + setup(&interlock, &controller, fixture_config()); + jj_inputs_t input = manual_inputs(30.0f); + input.mode = JJ_MODE_OFF; + (void)run_cycle(&interlock, &controller, &input); + + input.mode = JJ_MODE_MANUAL; + const cycle_t c = run_cycle(&interlock, &controller, &input); + CHECK(c.controller.stepped); + CHECK(c.controller.integrating); + CHECK(c.controller.setpoint_c == JJ_MANUAL_TARGET_DEFAULT_C); + CHECK(c.output.controller_state == JJ_CONTROLLER_VALID); + CHECK(c.output.heater_authorized); + CHECK(c.output.requested_duty_pct == c.controller.pid.output); + CHECK(c.output.requested_duty_pct > 0.0f); + CHECK(c.output.allowed_duty_pct == c.output.requested_duty_pct); +} + +static void test_provisional_config_is_valid_and_requests_nothing(void) +{ + jj_interlock_t interlock; + jj_controller_t controller; + setup(&interlock, &controller, jj_controller_provisional_config()); + const jj_inputs_t input = manual_inputs(30.0f); + const cycle_t c = run_cycle(&interlock, &controller, &input); + CHECK(c.output.controller_state == JJ_CONTROLLER_VALID); + CHECK(c.output.requested_duty_pct == 0.0f); + CHECK(c.output.allowed_duty_pct == 0.0f); +} + +static void test_rejected_step_is_invalid_and_cold(void) +{ + jj_controller_config_t bad[3]; + bad[0] = fixture_config(); + bad[0].pid.kd = 1.0f; /* kd without derivative_alpha */ + bad[1] = fixture_config(); + bad[1].sample_period_s = 0.0f; /* dt must be positive */ + bad[2] = fixture_config(); + bad[2].pid.output_max = NAN; + for (size_t i = 0; i < sizeof bad / sizeof bad[0]; ++i) { + jj_interlock_t interlock; + jj_controller_t controller; + setup(&interlock, &controller, bad[i]); + const jj_inputs_t input = manual_inputs(30.0f); + for (int n = 0; n < 5; ++n) { + const cycle_t c = run_cycle(&interlock, &controller, &input); + CHECK(c.controller.stepped); + CHECK(c.controller.report.state == JJ_CONTROLLER_INVALID); + CHECK(c.output.controller_state == JJ_CONTROLLER_INVALID); + CHECK(c.output.requested_duty_pct == 0.0f); + check_cold(&c); + CHECK(c.output.block_reason == JJ_BLOCK_CONTROLLER_INVALID); + } + } +} + +static void test_automatic_is_idle_before_phase_four(void) +{ + jj_interlock_t interlock; + jj_controller_t controller; + setup(&interlock, &controller, fixture_config()); + jj_inputs_t input = manual_inputs(30.0f); + input.mode = JJ_MODE_AUTOMATIC; + input.active_authority = JJ_AUTHORITY_AUTOMATIC; + input.automatic_target_available = true; + input.automatic_target_c = 42.0f; + for (int n = 0; n < 5; ++n) { + const cycle_t c = run_cycle(&interlock, &controller, &input); + check_idle(&c); + CHECK(c.output.block_reason == JJ_BLOCK_AUTO_POLICY_UNAVAILABLE); + } + /* Even if eligibility said RUN, AUTOMATIC has no setpoint to control. */ + const jj_eligibility_t run = {.disposition = JJ_DISPOSITION_RUN}; + const jj_controller_result_t r = jj_controller_step(&controller, &run, &input); + CHECK(!r.stepped && r.report.state == JJ_CONTROLLER_IDLE); +} + +static void test_fan_proof_pending_holds_and_freezes_integral(void) +{ + jj_interlock_t interlock; + jj_controller_t controller; + setup(&interlock, &controller, fixture_config()); + jj_inputs_t input = manual_inputs(40.0f); + for (int n = 0; n < 4; ++n) (void)run_cycle(&interlock, &controller, &input); + const float integral = controller.pid.integral; + CHECK(integral > 0.0f); + + input.fan_proof = JJ_FAN_PROOF_PENDING; + for (int n = 0; n < 3; ++n) { + const cycle_t c = run_cycle(&interlock, &controller, &input); + CHECK(c.output.controller_disposition == JJ_DISPOSITION_HOLD); + CHECK(c.controller.stepped); + CHECK(!c.controller.integrating); + CHECK(controller.pid.integral == integral); + CHECK(c.output.controller_state == JJ_CONTROLLER_VALID); + CHECK(c.output.requested_duty_pct > 0.0f); /* raw request, not zeroed */ + check_cold(&c); + CHECK(c.output.block_reason == JJ_BLOCK_FAN_PROOF_PENDING); + } + + /* Overlap with a SUSPEND-class inhibit suspends and resets. */ + jj_inputs_t no_authority = input; + no_authority.manual_demand_authorized = false; + cycle_t c = run_cycle(&interlock, &controller, &no_authority); + CHECK(c.output.block_reason == JJ_BLOCK_FAN_PROOF_PENDING); + CHECK(c.output.controller_disposition == JJ_DISPOSITION_SUSPEND); + check_idle(&c); + CHECK(controller.pid.integral == 0.0f); + + /* Resuming from a pure HOLD keeps the frozen integral. */ + setup(&interlock, &controller, fixture_config()); + input = manual_inputs(40.0f); + for (int n = 0; n < 4; ++n) (void)run_cycle(&interlock, &controller, &input); + const float kept = controller.pid.integral; + input.fan_proof = JJ_FAN_PROOF_PENDING; + (void)run_cycle(&interlock, &controller, &input); + input.fan_proof = JJ_FAN_PROOF_PROVEN; + c = run_cycle(&interlock, &controller, &input); + CHECK(c.controller.integrating); + CHECK(controller.pid.integral > kept); + CHECK(c.output.heater_authorized); +} + +static void test_fault_latch_keeps_controller_idle_until_safe_clear(void) +{ + jj_interlock_t interlock; + jj_controller_t controller; + setup(&interlock, &controller, fixture_config()); + jj_inputs_t input = manual_inputs(40.0f); + for (int n = 0; n < 4; ++n) (void)run_cycle(&interlock, &controller, &input); + CHECK(controller.pid.integral > 0.0f); + + jj_inputs_t faulted = input; + faulted.overtemperature_detected = true; + cycle_t c = run_cycle(&interlock, &controller, &faulted); + check_idle(&c); + CHECK(c.output.fault == JJ_FAULT_OVERTEMPERATURE); + CHECK(controller.pid.integral == 0.0f); + + /* Condition gone, MANUAL still has a target: the latch keeps it IDLE. */ + for (int n = 0; n < 20; ++n) { + c = run_cycle(&interlock, &controller, &input); + check_idle(&c); + CHECK(c.output.block_reason == JJ_BLOCK_FAULT_LATCHED); + CHECK(c.output.controller_disposition == JJ_DISPOSITION_SUSPEND); + CHECK(controller.pid.integral == 0.0f); + CHECK(!controller.pid.initialized); + } + + input.overtemperature_reset_proven = true; + CHECK(!jj_interlock_clear_fault(&interlock, &input)); /* not OFF */ + jj_inputs_t off = input; + off.mode = JJ_MODE_OFF; + CHECK(jj_interlock_clear_fault(&interlock, &off)); + c = run_cycle(&interlock, &controller, &off); + check_idle(&c); + c = run_cycle(&interlock, &controller, &input); + CHECK(c.output.controller_state == JJ_CONTROLLER_VALID); + CHECK(c.output.heater_authorized); +} + +static void test_category_b_inhibits_suspend_and_reset(void) +{ + jj_interlock_t interlock; + jj_controller_t controller; + setup(&interlock, &controller, fixture_config()); + jj_inputs_t input = manual_inputs(40.0f); + for (int n = 0; n < 4; ++n) (void)run_cycle(&interlock, &controller, &input); + CHECK(controller.pid.integral > 0.0f); + + jj_inputs_t bad_target = input; + bad_target.manual_target_c = 50.5f; + cycle_t c = run_cycle(&interlock, &controller, &bad_target); + check_idle(&c); + CHECK(c.output.block_reason == JJ_BLOCK_MANUAL_TARGET_INVALID); + CHECK(controller.pid.integral == 0.0f); + + for (int n = 0; n < 4; ++n) (void)run_cycle(&interlock, &controller, &input); + CHECK(controller.pid.integral > 0.0f); + jj_inputs_t uncommissioned = input; + uncommissioned.commissioned = false; + c = run_cycle(&interlock, &controller, &uncommissioned); + check_idle(&c); + CHECK(c.output.block_reason == JJ_BLOCK_NOT_COMMISSIONED); + CHECK(controller.pid.integral == 0.0f); +} + +static void test_no_target_crossing_override_outside_dc_pid(void) +{ + jj_interlock_t interlock; + jj_controller_t controller; + const jj_controller_config_t config = fixture_config(); + setup(&interlock, &controller, config); + dc_pid_state_t shadow; + dc_pid_reset(&shadow); + + /* Rise through the target and fall back through it. */ + const float trajectory[] = {38, 40, 42, 44, 44.8f, 45.2f, 45.6f, 45.9f, + 45.6f, 45.2f, 44.9f, 44.6f}; + bool nonzero_above_target = false; + for (size_t i = 0; i < sizeof trajectory / sizeof trajectory[0]; ++i) { + const jj_inputs_t input = manual_inputs(trajectory[i]); + const cycle_t c = run_cycle(&interlock, &controller, &input); + dc_pid_result_t expected; + CHECK(dc_pid_step(&shadow, &config.pid, JJ_MANUAL_TARGET_DEFAULT_C, + trajectory[i], config.sample_period_s, true, &expected)); + /* Requested and allowed duty are exactly dc_pid's output, every cycle. */ + CHECK(c.output.requested_duty_pct == expected.output); + CHECK(c.output.allowed_duty_pct == expected.output); + CHECK(c.output.heater_authorized); + if (trajectory[i] > JJ_MANUAL_TARGET_DEFAULT_C && expected.output > 0.0f) + nonzero_above_target = true; + } + CHECK(nonzero_above_target); /* the fixture actually exercises the case */ +} + +static void test_controller_defers_to_eligibility(void) +{ + /* + * jj_control has no fan/fault/printer/authority/sensor-status predicates: + * given RUN it steps even on inputs the interlock would refuse, and given + * SUSPEND it idles on inputs the interlock would allow. + */ + jj_controller_t controller; + const jj_controller_config_t config = fixture_config(); + jj_controller_init(&controller, &config); + jj_inputs_t hostile = manual_inputs(40.0f); + hostile.fan_proof = JJ_FAN_PROOF_FAILED; + hostile.overtemperature_detected = true; + hostile.commissioned = false; + hostile.manual_demand_authorized = false; + hostile.outlet.status = JJ_SENSOR_OPEN; + const jj_eligibility_t run = {.sequence = 7, .disposition = JJ_DISPOSITION_RUN}; + jj_controller_result_t r = jj_controller_step(&controller, &run, &hostile); + CHECK(r.stepped && r.report.state == JJ_CONTROLLER_VALID); + CHECK(r.report.eligibility_sequence == 7); + + const jj_eligibility_t suspend = {.disposition = JJ_DISPOSITION_SUSPEND}; + const jj_inputs_t fine = manual_inputs(40.0f); + r = jj_controller_step(&controller, &suspend, &fine); + CHECK(!r.stepped && r.report.state == JJ_CONTROLLER_IDLE); + + const jj_eligibility_t garbage = {.disposition = (jj_controller_disposition_t)9}; + r = jj_controller_step(&controller, &garbage, &fine); + CHECK(!r.stepped && r.report.state == JJ_CONTROLLER_INVALID); + CHECK(jj_controller_step(NULL, &run, &fine).report.state == + JJ_CONTROLLER_INVALID); +} + +static void test_invalid_never_yields_allowed_duty(void) +{ + /* Sweep modes, dispositions, and configs: allowed > 0 only when VALID+RUN. */ + const jj_mode_t modes[] = {JJ_MODE_OFF, JJ_MODE_MANUAL, JJ_MODE_AUTOMATIC}; + const jj_fan_proof_t fans[] = {JJ_FAN_PROOF_PROVEN, JJ_FAN_PROOF_PENDING}; + for (int bad = 0; bad < 2; ++bad) + for (size_t m = 0; m < 3; ++m) + for (size_t f = 0; f < 2; ++f) { + jj_interlock_t interlock; + jj_controller_t controller; + jj_controller_config_t config = fixture_config(); + if (bad) config.sample_period_s = -1.0f; + setup(&interlock, &controller, config); + jj_inputs_t input = manual_inputs(30.0f); + input.mode = modes[m]; + input.fan_proof = fans[f]; + const cycle_t c = run_cycle(&interlock, &controller, &input); + if (c.output.allowed_duty_pct > 0.0f || c.output.heater_authorized) { + CHECK(c.output.controller_state == JJ_CONTROLLER_VALID); + CHECK(c.output.controller_disposition == JJ_DISPOSITION_RUN); + } + if (c.output.controller_state != JJ_CONTROLLER_VALID) check_cold(&c); + } +} + +static void test_target_change_behavior_is_provisional(void) +{ + /* + * PROVISIONAL: documents current mechanics only. No target-change policy + * is implemented; dc_pid keeps its integral across a setpoint change. + * Preserve vs reinitialize is open pending Phase 3 evidence. + */ + jj_interlock_t interlock; + jj_controller_t controller; + setup(&interlock, &controller, fixture_config()); + jj_inputs_t input = manual_inputs(40.0f); + for (int n = 0; n < 4; ++n) (void)run_cycle(&interlock, &controller, &input); + const float before = controller.pid.integral; + input.manual_target_c = 35.0f; + const cycle_t c = run_cycle(&interlock, &controller, &input); + CHECK(c.output.controller_state == JJ_CONTROLLER_VALID); + CHECK(c.controller.pid.d == 0.0f); /* no derivative term, so no kick here */ + CHECK(controller.pid.initialized); + CHECK(before > 0.0f); +} + +int main(void) +{ + test_boot_and_off_are_idle(); + test_off_to_manual_steps_and_authorizes(); + test_provisional_config_is_valid_and_requests_nothing(); + test_rejected_step_is_invalid_and_cold(); + test_automatic_is_idle_before_phase_four(); + test_fan_proof_pending_holds_and_freezes_integral(); + test_fault_latch_keeps_controller_idle_until_safe_clear(); + test_category_b_inhibits_suspend_and_reset(); + test_no_target_crossing_override_outside_dc_pid(); + test_controller_defers_to_eligibility(); + test_invalid_never_yields_allowed_duty(); + test_target_change_behavior_is_provisional(); + puts("jj_control_host_test: PASS"); + return 0; +} diff --git a/tests/jj_interlock_host_test.c b/tests/jj_interlock_host_test.c index 63da916..165e26a 100644 --- a/tests/jj_interlock_host_test.c +++ b/tests/jj_interlock_host_test.c @@ -1,5 +1,7 @@ #include "jj_interlock.h" #include +#include +#include #include #include @@ -28,16 +30,40 @@ static jj_inputs_t nominal(jj_mode_t mode) return input; } +/* + * One staged cycle with a well-formed VALID controller report. Exercises the + * interlock alone: Stage 2 must treat this caller-built report as untrusted. + */ +static jj_outputs_t cycle_with(jj_interlock_t *state, const jj_inputs_t *input, + jj_controller_state_t controller, float duty) +{ + const jj_eligibility_t eligibility = jj_interlock_evaluate(state, input); + const jj_controller_report_t report = { + .eligibility_sequence = eligibility.sequence, + .state = eligibility.disposition == JJ_DISPOSITION_SUSPEND + ? JJ_CONTROLLER_IDLE : controller, + .requested_duty_pct = eligibility.disposition == JJ_DISPOSITION_SUSPEND + ? 0.0f : duty, + }; + return jj_interlock_authorize(state, &report); +} + +static jj_outputs_t cycle(jj_interlock_t *state, const jj_inputs_t *input) +{ + return cycle_with(state, input, JJ_CONTROLLER_VALID, 50.0f); +} + static jj_outputs_t step_once(jj_inputs_t input) { jj_interlock_t state; jj_interlock_init(&state); - return jj_interlock_step(&state, &input); + return cycle(&state, &input); } static void check_cold(jj_outputs_t output, jj_block_reason_t reason) { - CHECK(!output.heater_requested); + CHECK(!output.heater_authorized); + CHECK(output.allowed_duty_pct == 0.0f); CHECK(output.effective_target_c == 0.0f); CHECK(output.block_reason == reason); } @@ -64,7 +90,7 @@ static void test_manual_target_is_rejected_not_clamped(void) jj_inputs_t input = nominal(JJ_MODE_MANUAL); input.manual_target_c = accepted[i]; jj_outputs_t output = step_once(input); - CHECK(output.heater_requested); + CHECK(output.heater_authorized); CHECK(output.effective_target_c == accepted[i]); } const float rejected[] = {29.9f, 50.1f, NAN, INFINITY, -INFINITY}; @@ -120,12 +146,12 @@ static void test_faults_latch_and_need_explicit_safe_clear(void) jj_interlock_init(&state); jj_inputs_t input = nominal(JJ_MODE_MANUAL); input.chamber.status = JJ_SENSOR_OPEN; - jj_outputs_t output = jj_interlock_step(&state, &input); + jj_outputs_t output = cycle(&state, &input); CHECK(output.fault == JJ_FAULT_SENSOR); check_cold(output, JJ_BLOCK_FAULT_LATCHED); input = nominal(JJ_MODE_MANUAL); - CHECK(jj_interlock_step(&state, &input).fault == JJ_FAULT_SENSOR); + CHECK(cycle(&state, &input).fault == JJ_FAULT_SENSOR); CHECK(!jj_interlock_clear_fault(&state, &input)); input.mode = JJ_MODE_OFF; input.cooldown_required = true; @@ -136,7 +162,7 @@ static void test_faults_latch_and_need_explicit_safe_clear(void) jj_interlock_init(&state); input = nominal(JJ_MODE_MANUAL); input.overtemperature_detected = true; - output = jj_interlock_step(&state, &input); + output = cycle(&state, &input); CHECK(output.fault == JJ_FAULT_OVERTEMPERATURE); check_cold(output, JJ_BLOCK_FAULT_LATCHED); } @@ -169,8 +195,15 @@ static void test_fan_proof_and_null_inputs_fail_cold(void) jj_interlock_t state; jj_interlock_init(&state); - check_cold(jj_interlock_step(NULL, &input), JJ_BLOCK_FAULT_LATCHED); - check_cold(jj_interlock_step(&state, NULL), JJ_BLOCK_FAULT_LATCHED); + const jj_eligibility_t null_eligibility = jj_interlock_evaluate(NULL, &input); + CHECK(null_eligibility.disposition == JJ_DISPOSITION_SUSPEND); + CHECK(null_eligibility.dominant_reason == JJ_BLOCK_FAULT_LATCHED); + CHECK(jj_interlock_evaluate(&state, NULL).disposition == JJ_DISPOSITION_SUSPEND); + const jj_controller_report_t report = {.state = JJ_CONTROLLER_VALID, + .requested_duty_pct = 50.0f}; + check_cold(jj_interlock_authorize(NULL, &report), JJ_BLOCK_FAULT_LATCHED); + CHECK(!jj_interlock_authorize(&state, &report).heater_authorized); + CHECK(!jj_interlock_authorize(&state, NULL).heater_authorized); } static void test_remote_fault_acknowledgement_policy(void) @@ -247,6 +280,320 @@ static void test_remote_fault_acknowledgement_policy(void) CHECK(jj_interlock_clear_fault(&state, &input)); } + +static void test_disposition_classification_is_pinned(void) +{ + /* Changing any disposition must be a deliberate, reviewed test change. */ + for (int r = 0; r <= (int)JJ_BLOCK_REASON_COUNT; ++r) { + const jj_block_reason_t reason = (jj_block_reason_t)r; + jj_controller_disposition_t expected = JJ_DISPOSITION_SUSPEND; + if (reason == JJ_BLOCK_NONE) expected = JJ_DISPOSITION_RUN; + if (reason == JJ_BLOCK_FAN_PROOF_PENDING) expected = JJ_DISPOSITION_HOLD; + CHECK(jj_block_reason_disposition(reason) == expected); + } + CHECK(jj_block_reason_disposition((jj_block_reason_t)999) == + JJ_DISPOSITION_SUSPEND); + CHECK(JJ_DISPOSITION_SUSPEND < JJ_DISPOSITION_HOLD); + CHECK(JJ_DISPOSITION_HOLD < JJ_DISPOSITION_RUN); + CHECK(JJ_DISPOSITION_SUSPEND == 0 && JJ_CONTROLLER_IDLE == 0); +} + +static void test_overlapping_inhibits_aggregate_conservatively(void) +{ + jj_interlock_t state; + jj_interlock_init(&state); + jj_inputs_t input = nominal(JJ_MODE_MANUAL); + CHECK(jj_interlock_evaluate(&state, &input).disposition == JJ_DISPOSITION_RUN); + + input.fan_proof = JJ_FAN_PROOF_PENDING; + jj_eligibility_t e = jj_interlock_evaluate(&state, &input); + CHECK(e.disposition == JJ_DISPOSITION_HOLD); + CHECK(e.dominant_reason == JJ_BLOCK_FAN_PROOF_PENDING); + + /* Same dominant diagnostic reason, but disposition sees every inhibit. */ + jj_inputs_t no_authority = input; + no_authority.manual_demand_authorized = false; + e = jj_interlock_evaluate(&state, &no_authority); + CHECK(e.dominant_reason == JJ_BLOCK_FAN_PROOF_PENDING); + CHECK(e.disposition == JJ_DISPOSITION_SUSPEND); + CHECK(e.inhibit_mask & (UINT32_C(1) << JJ_BLOCK_FAN_PROOF_PENDING)); + CHECK(e.inhibit_mask & (UINT32_C(1) << JJ_BLOCK_CONTROL_NO_AUTHORITY)); + + jj_inputs_t refresh = input; + refresh.active_authority = JJ_AUTHORITY_REACQUIRING; + CHECK(jj_interlock_evaluate(&state, &refresh).disposition == + JJ_DISPOSITION_SUSPEND); + + jj_inputs_t bad_target = input; + bad_target.manual_target_c = 60.0f; + e = jj_interlock_evaluate(&state, &bad_target); + CHECK(e.dominant_reason == JJ_BLOCK_FAN_PROOF_PENDING); + CHECK(e.disposition == JJ_DISPOSITION_SUSPEND); + + jj_inputs_t automatic = nominal(JJ_MODE_AUTOMATIC); + automatic.fan_proof = JJ_FAN_PROOF_PENDING; + CHECK(jj_interlock_evaluate(&state, &automatic).disposition == + JJ_DISPOSITION_SUSPEND); + + /* Fault latch dominates and suspends regardless of other conditions. */ + jj_inputs_t faulted = input; + faulted.overtemperature_detected = true; + e = jj_interlock_evaluate(&state, &faulted); + CHECK(e.dominant_reason == JJ_BLOCK_FAULT_LATCHED); + CHECK(e.disposition == JJ_DISPOSITION_SUSPEND); +} + +static void test_authorization_requires_valid_controller(void) +{ + jj_inputs_t input = nominal(JJ_MODE_MANUAL); + jj_interlock_t state; + + jj_interlock_init(&state); + jj_outputs_t out = cycle_with(&state, &input, JJ_CONTROLLER_INVALID, 80.0f); + check_cold(out, JJ_BLOCK_CONTROLLER_INVALID); + CHECK(out.controller_state == JJ_CONTROLLER_INVALID); + CHECK(out.requested_duty_pct == 0.0f); + + jj_interlock_init(&state); + out = cycle_with(&state, &input, JJ_CONTROLLER_IDLE, 0.0f); + check_cold(out, JJ_BLOCK_CONTROLLER_INVALID); + + const float malformed[] = {NAN, INFINITY, -1.0f, 100.5f}; + for (size_t i = 0; i < sizeof malformed / sizeof malformed[0]; ++i) { + jj_interlock_init(&state); + out = cycle_with(&state, &input, JJ_CONTROLLER_VALID, malformed[i]); + check_cold(out, JJ_BLOCK_CONTROLLER_INVALID); + CHECK(out.controller_state == JJ_CONTROLLER_INVALID); + CHECK(out.requested_duty_pct == 0.0f); + } + jj_interlock_init(&state); + out = cycle_with(&state, &input, (jj_controller_state_t)77, 50.0f); + check_cold(out, JJ_BLOCK_CONTROLLER_INVALID); + + /* A legitimate VALID 0 % request is authorized and distinct from denial. */ + jj_interlock_init(&state); + out = cycle_with(&state, &input, JJ_CONTROLLER_VALID, 0.0f); + CHECK(out.heater_authorized); + CHECK(out.controller_state == JJ_CONTROLLER_VALID); + CHECK(out.allowed_duty_pct == 0.0f); + CHECK(out.block_reason == JJ_BLOCK_NONE); + + jj_interlock_init(&state); + out = cycle_with(&state, &input, JJ_CONTROLLER_VALID, 37.5f); + CHECK(out.heater_authorized); + CHECK(out.requested_duty_pct == 37.5f); + CHECK(out.allowed_duty_pct == 37.5f); + CHECK(out.effective_target_c == JJ_MANUAL_TARGET_DEFAULT_C); + CHECK(out.thermal_state == JJ_THERMAL_HEATING); +} + +static void test_safety_never_rewrites_valid_request(void) +{ + jj_interlock_t state; + jj_interlock_init(&state); + jj_inputs_t input = nominal(JJ_MODE_MANUAL); + input.fan_proof = JJ_FAN_PROOF_PENDING; + jj_outputs_t out = cycle_with(&state, &input, JJ_CONTROLLER_VALID, 42.0f); + check_cold(out, JJ_BLOCK_FAN_PROOF_PENDING); + CHECK(out.controller_disposition == JJ_DISPOSITION_HOLD); + CHECK(out.controller_state == JJ_CONTROLLER_VALID); + CHECK(out.requested_duty_pct == 42.0f); + + /* Stepping while suspended is a controller contract violation. */ + jj_inputs_t off = nominal(JJ_MODE_OFF); + const jj_eligibility_t e = jj_interlock_evaluate(&state, &off); + const jj_controller_report_t rogue = { + .eligibility_sequence = e.sequence, + .state = JJ_CONTROLLER_VALID, + .requested_duty_pct = 90.0f, + }; + out = jj_interlock_authorize(&state, &rogue); + check_cold(out, JJ_BLOCK_OFF); + CHECK(out.controller_state == JJ_CONTROLLER_INVALID); +} + +static void test_stage_two_consumes_only_its_own_stage_one(void) +{ + jj_interlock_t state; + jj_inputs_t input = nominal(JJ_MODE_MANUAL); + const jj_controller_report_t any = {.state = JJ_CONTROLLER_VALID, + .requested_duty_pct = 50.0f}; + + /* Authorize with no Stage 1 at all. */ + jj_interlock_init(&state); + CHECK(!jj_interlock_authorize(&state, &any).heater_authorized); + + /* Wrong token: denied, and the pending evaluation is consumed. */ + jj_interlock_init(&state); + jj_eligibility_t e = jj_interlock_evaluate(&state, &input); + jj_controller_report_t report = {.eligibility_sequence = e.sequence + 1, + .state = JJ_CONTROLLER_VALID, + .requested_duty_pct = 50.0f}; + jj_outputs_t out = jj_interlock_authorize(&state, &report); + check_cold(out, JJ_BLOCK_CONTROL_SEQUENCE_INVALID); + report.eligibility_sequence = e.sequence; + CHECK(!jj_interlock_authorize(&state, &report).heater_authorized); + + /* Replay: one Stage 1 authorizes at most one Stage 2. */ + jj_interlock_init(&state); + e = jj_interlock_evaluate(&state, &input); + report.eligibility_sequence = e.sequence; + CHECK(jj_interlock_authorize(&state, &report).heater_authorized); + out = jj_interlock_authorize(&state, &report); + check_cold(out, JJ_BLOCK_CONTROL_SEQUENCE_INVALID); + CHECK(!jj_interlock_snapshot(&state).heater_authorized); + + /* Stale: a newer Stage 1 supersedes an older one. */ + jj_interlock_init(&state); + const jj_eligibility_t older = jj_interlock_evaluate(&state, &input); + (void)jj_interlock_evaluate(&state, &input); + report.eligibility_sequence = older.sequence; + CHECK(!jj_interlock_authorize(&state, &report).heater_authorized); + + /* A caller cannot manufacture eligibility: Stage 2 uses its own copy. */ + jj_interlock_init(&state); + jj_inputs_t off = nominal(JJ_MODE_OFF); + e = jj_interlock_evaluate(&state, &off); + jj_eligibility_t forged = e; + forged.disposition = JJ_DISPOSITION_RUN; + forged.dominant_reason = JJ_BLOCK_NONE; + report.eligibility_sequence = forged.sequence; + CHECK(!jj_interlock_authorize(&state, &report).heater_authorized); + + /* Revocation between the stages voids the pending evaluation. */ + jj_interlock_init(&state); + e = jj_interlock_evaluate(&state, &input); + jj_interlock_remove_remote_authorization( + &state, JJ_AUTHORITY_NONE, JJ_CONTROL_INHIBIT_NO_AUTHORITY, + JJ_BLOCK_CONTROL_NO_AUTHORITY); + report.eligibility_sequence = e.sequence; + out = jj_interlock_authorize(&state, &report); + CHECK(!out.heater_authorized); + CHECK(out.block_reason == JJ_BLOCK_CONTROL_NO_AUTHORITY); + + /* Fault clear between the stages also voids it. */ + jj_interlock_init(&state); + jj_inputs_t clear = nominal(JJ_MODE_OFF); + e = jj_interlock_evaluate(&state, &input); + state.fault_latched = JJ_FAULT_SENSOR; + CHECK(jj_interlock_clear_fault(&state, &clear)); + report.eligibility_sequence = e.sequence; + CHECK(!jj_interlock_authorize(&state, &report).heater_authorized); + + /* Stage 1 immediately withdraws any earlier authorization. */ + jj_interlock_init(&state); + CHECK(cycle(&state, &input).heater_authorized); + CHECK(jj_interlock_snapshot(&state).heater_authorized); + (void)jj_interlock_evaluate(&state, &input); + CHECK(!jj_interlock_snapshot(&state).heater_authorized); + CHECK(jj_interlock_snapshot(&state).allowed_duty_pct == 0.0f); +} + + +/* + * Mirrors jj_portal's thermal_state_guard(): "unauthorized" alone is not + * "thermally safe for OTA". check_ota_contract.sh pins the portal expression. + */ +static bool ota_guard_allows(jj_outputs_t output) +{ + return !output.heater_authorized && !output.thermal_management_required; +} + +/* Run one authorized cycle, then Stage 1 only; return the window snapshot. */ +static jj_outputs_t window_after_heating(jj_interlock_t *state, + const jj_inputs_t *next) +{ + jj_interlock_init(state); + const jj_inputs_t heating = nominal(JJ_MODE_MANUAL); + CHECK(cycle(state, &heating).heater_authorized); + CHECK(!ota_guard_allows(jj_interlock_snapshot(state))); + (void)jj_interlock_evaluate(state, next); + return jj_interlock_snapshot(state); +} + +static void test_stage_one_window_keeps_ota_blocked(void) +{ + jj_interlock_t state; + + /* A: still heating-eligible; Stage 1 withdraws authorization only. */ + jj_inputs_t input = nominal(JJ_MODE_MANUAL); + jj_outputs_t window = window_after_heating(&state, &input); + CHECK(!window.heater_authorized); + CHECK(window.allowed_duty_pct == 0.0f); + CHECK(window.thermal_management_required); + CHECK(!ota_guard_allows(window)); + + /* B: cooldown required going into Stage 1, in any mode. */ + const jj_mode_t modes[] = {JJ_MODE_MANUAL, JJ_MODE_OFF, JJ_MODE_AUTOMATIC}; + for (size_t m = 0; m < 3; ++m) { + input = nominal(modes[m]); + input.cooldown_required = true; + window = window_after_heating(&state, &input); + CHECK(!window.heater_authorized); + CHECK(window.thermal_management_required); + CHECK(!ota_guard_allows(window)); + } + + /* C: a fault requiring thermal management going into Stage 1. */ + for (size_t m = 0; m < 3; ++m) { + input = nominal(modes[m]); + input.fault_requires_thermal_management = true; + window = window_after_heating(&state, &input); + CHECK(!window.heater_authorized); + CHECK(window.thermal_management_required); + CHECK(!ota_guard_allows(window)); + } +} + +static void test_stage_one_and_two_agree_on_thermal_management(void) +{ + /* + * D: Stage 2 may change authorization but never the thermal-management + * condition, so the window cannot open an OTA path the final state lacks. + */ + const jj_mode_t modes[] = {JJ_MODE_OFF, JJ_MODE_MANUAL, JJ_MODE_AUTOMATIC}; + const jj_fan_proof_t fans[] = {JJ_FAN_PROOF_UNAVAILABLE, JJ_FAN_PROOF_PENDING, + JJ_FAN_PROOF_PROVEN, JJ_FAN_PROOF_FAILED}; + const jj_controller_state_t controllers[] = { + JJ_CONTROLLER_IDLE, JJ_CONTROLLER_VALID, JJ_CONTROLLER_INVALID}; + int cases = 0; + for (int prev = 0; prev < 2; ++prev) + for (size_t m = 0; m < 3; ++m) + for (size_t f = 0; f < 4; ++f) + for (int cooldown = 0; cooldown < 2; ++cooldown) + for (int fault_tm = 0; fault_tm < 2; ++fault_tm) + for (int authority = 0; authority < 2; ++authority) + for (int overtemp = 0; overtemp < 2; ++overtemp) + for (size_t c = 0; c < 3; ++c) { + jj_interlock_t state; + jj_interlock_init(&state); + if (prev) { + const jj_inputs_t heating = nominal(JJ_MODE_MANUAL); + CHECK(cycle(&state, &heating).heater_authorized); + } + jj_inputs_t input = nominal(modes[m]); + input.fan_proof = fans[f]; + input.cooldown_required = cooldown; + input.fault_requires_thermal_management = fault_tm; + input.manual_demand_authorized = authority; + input.overtemperature_detected = overtemp; + const jj_eligibility_t e = jj_interlock_evaluate(&state, &input); + const jj_outputs_t window = jj_interlock_snapshot(&state); + const jj_controller_report_t report = { + .eligibility_sequence = e.sequence, + .state = controllers[c], + .requested_duty_pct = 50.0f, + }; + const jj_outputs_t final = jj_interlock_authorize(&state, &report); + CHECK(!window.heater_authorized); + CHECK(window.thermal_management_required == final.thermal_management_required); + if (ota_guard_allows(window)) CHECK(ota_guard_allows(final)); + if (cooldown || fault_tm) CHECK(!ota_guard_allows(window)); + ++cases; + } + CHECK(cases == 2 * 3 * 4 * 2 * 2 * 2 * 2 * 3); +} + int main(void) { test_boot_defaults_and_modes(); @@ -257,6 +604,13 @@ int main(void) test_off_keeps_thermal_management_request(); test_fan_proof_and_null_inputs_fail_cold(); test_remote_fault_acknowledgement_policy(); + test_disposition_classification_is_pinned(); + test_overlapping_inhibits_aggregate_conservatively(); + test_authorization_requires_valid_controller(); + test_safety_never_rewrites_valid_request(); + test_stage_two_consumes_only_its_own_stage_one(); + test_stage_one_window_keeps_ota_blocked(); + test_stage_one_and_two_agree_on_thermal_management(); puts("jj_interlock_host_test: PASS"); return 0; } diff --git a/tests/run_authority_host_test.sh b/tests/run_authority_host_test.sh index f4b8d56..fe60ded 100755 --- a/tests/run_authority_host_test.sh +++ b/tests/run_authority_host_test.sh @@ -1,13 +1,18 @@ #!/usr/bin/env bash set -euo pipefail root=$(cd "$(dirname "$0")/.." && pwd) +dc_pid=$("$root/tests/fetch_dc_pid.sh") out=$(mktemp) trap 'rm -f "$out"' EXIT cc -std=c11 -Wall -Wextra -Werror \ -fsanitize=undefined -fno-sanitize-recover=undefined \ -I"$root/components/jj_interlock/include" \ -I"$root/components/jj_authority/include" \ + -I"$root/components/jj_control/include" \ + -I"$dc_pid/include" \ "$root/components/jj_interlock/jj_interlock.c" \ "$root/components/jj_authority/jj_authority.c" \ + "$root/components/jj_control/jj_control.c" \ + "$dc_pid/dc_pid.c" \ "$root/tests/jj_authority_host_test.c" -lm -o "$out" "$out" diff --git a/tests/run_control_host_test.sh b/tests/run_control_host_test.sh new file mode 100755 index 0000000..ebcee73 --- /dev/null +++ b/tests/run_control_host_test.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +dc_pid=$("$root/tests/fetch_dc_pid.sh") +out=$(mktemp) +trap 'rm -f "$out"' EXIT +cc -std=c11 -Wall -Wextra -Werror \ + -fsanitize=undefined -fno-sanitize-recover=undefined \ + -I"$root/components/jj_interlock/include" \ + -I"$root/components/jj_control/include" \ + -I"$dc_pid/include" \ + "$root/components/jj_interlock/jj_interlock.c" \ + "$root/components/jj_control/jj_control.c" \ + "$dc_pid/dc_pid.c" \ + "$root/tests/jj_control_host_test.c" -lm -o "$out" +"$out"