Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/firmware-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion components/jj_authority/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 6 additions & 0 deletions components/jj_authority/include/jj_authority.h
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once

#include "jj_control.h"
#include "jj_interlock.h"
#include <stdbool.h>
#include <stdint.h>
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 11 additions & 1 deletion components/jj_authority/jj_authority.c
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions components/jj_control/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
50 changes: 50 additions & 0 deletions components/jj_control/include/jj_control.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once

#include "dc_pid.h"
#include "jj_interlock.h"
#include <stdbool.h>

/*
* 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);
108 changes: 108 additions & 0 deletions components/jj_control/jj_control.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "jj_control.h"
#include <math.h>
#include <string.h>

/*
* 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;
}
74 changes: 71 additions & 3 deletions components/jj_interlock/include/jj_interlock.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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);
Loading
Loading