From e34468fd2a76b2d3f8c0712075a5ac4d9666f0ad Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:50:27 -0700 Subject: [PATCH 1/5] feat(control): add lease-safe authority model --- .github/workflows/firmware-build.yml | 9 + README.md | 8 + ROADMAP.md | 3 +- TODO.md | 4 +- components/jj_authority/CMakeLists.txt | 6 + .../jj_authority/include/jj_authority.h | 112 +++++ components/jj_authority/jj_authority.c | 431 ++++++++++++++++++ .../jj_interlock/include/jj_interlock.h | 31 +- components/jj_interlock/jj_interlock.c | 102 ++++- components/jj_portal/CMakeLists.txt | 3 +- components/jj_portal/include/jj_portal.h | 3 +- components/jj_portal/jj_portal.c | 424 +++++++++++++++-- components/jj_portal/web/control-client.js | 197 ++++++++ components/jj_portal/web/control.html | 136 ++++++ docs/ARCHITECTURE.md | 32 +- docs/CONTROL_AUTHORITY.md | 88 ++++ docs/PRODUCT_SAFETY_CONTRACT.md | 40 +- main/CMakeLists.txt | 2 +- main/app_main.c | 11 +- tests/check_api_contract.sh | 13 + tests/check_ota_contract.sh | 2 +- tests/control-client.test.cjs | 165 +++++++ tests/jj_authority_host_test.c | 423 +++++++++++++++++ tests/jj_interlock_host_test.c | 30 +- tests/run_authority_host_test.sh | 13 + 25 files changed, 2237 insertions(+), 51 deletions(-) create mode 100644 components/jj_authority/CMakeLists.txt create mode 100644 components/jj_authority/include/jj_authority.h create mode 100644 components/jj_authority/jj_authority.c create mode 100644 components/jj_portal/web/control-client.js create mode 100644 components/jj_portal/web/control.html create mode 100644 docs/CONTROL_AUTHORITY.md create mode 100644 tests/control-client.test.cjs create mode 100644 tests/jj_authority_host_test.c create mode 100755 tests/run_authority_host_test.sh diff --git a/.github/workflows/firmware-build.yml b/.github/workflows/firmware-build.yml index 5176e37..5350431 100644 --- a/.github/workflows/firmware-build.yml +++ b/.github/workflows/firmware-build.yml @@ -29,6 +29,15 @@ jobs: - name: Test heater interlock state machine run: ./tests/run_interlock_host_test.sh + - name: Test control authority state machine + run: ./tests/run_authority_host_test.sh + + - name: Test browser control client + run: node --test tests/control-client.test.cjs + + - name: Check browser JavaScript syntax + run: node --check components/jj_portal/web/control-client.js + - name: Check canonical product identity run: sh tests/check_identity_contract.sh diff --git a/README.md b/README.md index 7ad91a2..cff2dd7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,11 @@ heater safety decisions local to Jump Jet. - `dc_wifi`, `dc_portal`, `dc_ui`, `dc_evlog`, and read-only `dc_prusa` pinned to dragon-core v0.32.0 (`4e041d864763d468a50e9649807827dd83dd54bc`) - exactly three product modes: OFF, MANUAL, and AUTOMATIC; boot/reset is always OFF +- orthogonal NONE/REMOTE/AUTOMATIC/REACQUIRING authority, recoverable control + inhibits, latched safety faults, and autonomous cooldown state +- lease-bound MANUAL demand with explicit takeover, atomic revision-validated + mutations, and a reacquire-plus-refresh requirement after control loss +- product-local AUTOMATIC authority that does not depend on a browser heartbeat - manual target policy of 30–50 °C inclusive with a 45 °C default and rejection, never clamping, outside that range - `dc_prusa`'s existing 15-second freshness result used directly, with no second @@ -66,6 +71,8 @@ fault-injection evidence. The browser and API are never the safety boundary. ```sh ./tests/run_interlock_host_test.sh +./tests/run_authority_host_test.sh +node --test tests/control-client.test.cjs sh tests/check_identity_contract.sh sh tests/check_api_contract.sh sh tests/check_actuation_allowlist.sh @@ -82,6 +89,7 @@ For ESP-IDF 5.3 or newer: - [Product and safety contract](docs/PRODUCT_SAFETY_CONTRACT.md) - [Firmware architecture](docs/ARCHITECTURE.md) +- [Control-authority and mutation API](docs/CONTROL_AUTHORITY.md) - [Hardware baseline](docs/HARDWARE_BASELINE.md) - [CZ4060 characterization](docs/hardware/cz4060-characterization.md) - [Safety verification](docs/SAFETY_VERIFICATION.md) diff --git a/ROADMAP.md b/ROADMAP.md index 1687275..0d91b54 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -72,7 +72,8 @@ for every implemented fault. No printer installation at this phase. - allowlist exact `PRINTING` and fail all other states cold - derive and implement the bed-target-to-chamber-target production policy; keep AUTOMATIC unavailable until then -- implement validated, revision-aware API mutations and product settings +- integrate the implemented validated, generation/revision-aware API mutations + with release-intent sensing and persistent product settings - show temperatures, target, duty, fans, mode, PrusaLink state, bed target, interlocks, faults, firmware, and network state - advertise heating capabilities only after the corresponding hardware is commissioned diff --git a/TODO.md b/TODO.md index 8269f11..538932d 100644 --- a/TODO.md +++ b/TODO.md @@ -67,14 +67,14 @@ implementation starts, and keep only the summary checkbox here. ## UI, API, and OTA -- [ ] Finalize Jump Jet API v2 state and mutation schemas +- [x] Define Jump Jet API v2 control-authority state and atomic mutation schemas - [ ] Add chamber, outlet, and case temperature telemetry with sensor status - [ ] Add target, heater duty, both fan states, mode, and commissioning state - [ ] Add PrusaLink connection, printer state, bed temperature/target, and sample age - [ ] Define optional Klipper/Moonraker integration for read-only printer state and bed-target telemetry, with the same stale-data fail-cold semantics as PrusaLink - [ ] Add visible interlocks, latched fault reason, and safe recovery guidance - [ ] Add settings for PrusaLink and the bed-target policy with strict bounds -- [ ] Add revision/lease semantics for remote heat commands if manual remote heat is retained +- [x] Add generation/revision/lease semantics for remote Manual commands - [ ] Test immediate pre-upload OTA rejection while heating - [ ] Test the second heater-state guard before selecting the uploaded image for boot - [ ] Test wrong-product, corrupt, interrupted, and rollback OTA paths diff --git a/components/jj_authority/CMakeLists.txt b/components/jj_authority/CMakeLists.txt new file mode 100644 index 0000000..d2cd51e --- /dev/null +++ b/components/jj_authority/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "jj_authority.c" + INCLUDE_DIRS "include" + REQUIRES jj_interlock +) +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 new file mode 100644 index 0000000..74c1376 --- /dev/null +++ b/components/jj_authority/include/jj_authority.h @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "jj_interlock.h" +#include +#include + +#define JJ_CONTROL_LEASE_ID_LEN 32 +#define JJ_CONTROL_OWNER_LEN 31 +#define JJ_CONTROL_REQUEST_ID_LEN 63 +#define JJ_CONTROL_REQUEST_CACHE_SIZE 8 +#define JJ_REMOTE_LEASE_TTL_MS 60000U + +typedef enum { + JJ_CONTROL_OK = 0, + JJ_CONTROL_AUTHORITY_REQUIRED, + JJ_CONTROL_AUTHORITY_LOST, + JJ_CONTROL_GENERATION_STALE, + JJ_CONTROL_REVISION_CONFLICT, + JJ_CONTROL_REQUEST_INELIGIBLE, + JJ_CONTROL_REQUEST_CONFLICT, + JJ_CONTROL_HARDWARE_FAULT, + JJ_CONTROL_INVALID, +} jj_control_result_t; + +typedef enum { + JJ_MUTATION_OFF = 0, + JJ_MUTATION_MANUAL, + JJ_MUTATION_AUTOMATIC, + JJ_MUTATION_CLEAR_FAULT, +} jj_control_mutation_kind_t; + +typedef struct { + char lease_id[JJ_CONTROL_LEASE_ID_LEN + 1]; + char owner[JJ_CONTROL_OWNER_LEN + 1]; + bool takeover; +} jj_control_acquire_t; + +typedef struct { + char lease_id[JJ_CONTROL_LEASE_ID_LEN + 1]; + uint32_t generation; + uint32_t expected_revision; + char request_id[JJ_CONTROL_REQUEST_ID_LEN + 1]; + jj_control_mutation_kind_t kind; + float target_c; +} jj_control_mutation_t; + +typedef struct { + uint32_t revision; + uint32_t generation; + jj_mode_t mode; + float configured_target_c; + bool manual_demand_authorized; + jj_control_authority_t active_authority; + jj_control_inhibit_t control_inhibit; + bool lease_active; + char lease_id[JJ_CONTROL_LEASE_ID_LEN + 1]; + char lease_owner[JJ_CONTROL_OWNER_LEN + 1]; + uint32_t lease_expires_in_ms; + uint64_t last_control_loss_ms; + char last_control_loss_reason[32]; +} jj_control_snapshot_t; + +typedef struct { + char request_id[JJ_CONTROL_REQUEST_ID_LEN + 1]; + uint64_t signature; + jj_control_result_t result; +} jj_control_request_cache_entry_t; + +typedef struct { + jj_control_snapshot_t state; + uint64_t lease_deadline_ms; + uint32_t lease_ttl_ms; + jj_control_request_cache_entry_t request_cache[JJ_CONTROL_REQUEST_CACHE_SIZE]; + uint8_t next_request_cache_entry; +} jj_authority_t; + +void jj_authority_init(jj_authority_t *state, uint32_t lease_ttl_ms); +jj_control_result_t jj_authority_acquire( + jj_authority_t *state, + const jj_control_acquire_t *request, + uint64_t now_ms, + jj_control_snapshot_t *out); +jj_control_result_t jj_authority_heartbeat( + jj_authority_t *state, + const char *lease_id, + uint32_t generation, + uint64_t now_ms, + jj_control_snapshot_t *out); +jj_control_result_t jj_authority_refresh( + jj_authority_t *state, + const char *lease_id, + uint32_t generation, + uint64_t now_ms, + jj_control_snapshot_t *out); +jj_control_result_t jj_authority_mutate( + jj_authority_t *state, + jj_interlock_t *interlock, + const jj_control_mutation_t *request, + const jj_inputs_t *authoritative_inputs, + uint64_t now_ms, + jj_control_snapshot_t *out); +bool jj_authority_tick(jj_authority_t *state, uint64_t now_ms); +void jj_authority_snapshot( + const jj_authority_t *state, + uint64_t now_ms, + jj_control_snapshot_t *out); +void jj_authority_apply_to_inputs( + const jj_control_snapshot_t *authority, + jj_inputs_t *inputs); +const char *jj_control_result_str(jj_control_result_t result); +const char *jj_mode_str(jj_mode_t mode); diff --git a/components/jj_authority/jj_authority.c b/components/jj_authority/jj_authority.c new file mode 100644 index 0000000..46fe8e4 --- /dev/null +++ b/components/jj_authority/jj_authority.c @@ -0,0 +1,431 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "jj_authority.h" + +#include +#include +#include + +#ifdef ESP_PLATFORM +#include "freertos/FreeRTOS.h" +static portMUX_TYPE s_authority_lock = portMUX_INITIALIZER_UNLOCKED; +#define AUTHORITY_LOCK() portENTER_CRITICAL(&s_authority_lock) +#define AUTHORITY_UNLOCK() portEXIT_CRITICAL(&s_authority_lock) +#else +#define AUTHORITY_LOCK() ((void)0) +#define AUTHORITY_UNLOCK() ((void)0) +#endif + +static void copy_text(char *destination, size_t size, const char *source) +{ + if (!destination || size == 0) return; + snprintf(destination, size, "%s", source ? source : ""); +} + +static void advance(uint32_t *value) +{ + (*value)++; + if (*value == 0) *value = 1; +} + +static void snapshot_unlocked( + const jj_authority_t *state, + uint64_t now_ms, + jj_control_snapshot_t *out) +{ + if (!out) return; + *out = state->state; + if (out->lease_active && state->lease_deadline_ms > now_ms) { + const uint64_t remaining = state->lease_deadline_ms - now_ms; + out->lease_expires_in_ms = remaining > UINT32_MAX + ? UINT32_MAX : (uint32_t)remaining; + } else { + out->lease_expires_in_ms = 0; + } +} + +static bool lease_expire_unlocked(jj_authority_t *state, uint64_t now_ms) +{ + if (!state->state.lease_active || now_ms < state->lease_deadline_ms) + return false; + state->state.lease_active = false; + state->state.lease_id[0] = '\0'; + state->state.lease_owner[0] = '\0'; + state->lease_deadline_ms = 0; + advance(&state->state.generation); + advance(&state->state.revision); + state->state.last_control_loss_ms = now_ms; + copy_text(state->state.last_control_loss_reason, + sizeof state->state.last_control_loss_reason, + "remote_lease_expired"); + if (state->state.mode == JJ_MODE_MANUAL) { + state->state.manual_demand_authorized = false; + state->state.active_authority = JJ_AUTHORITY_NONE; + state->state.control_inhibit = JJ_CONTROL_INHIBIT_NO_AUTHORITY; + } + return true; +} + +void jj_authority_init(jj_authority_t *state, uint32_t lease_ttl_ms) +{ + if (!state) return; + AUTHORITY_LOCK(); + memset(state, 0, sizeof *state); + state->lease_ttl_ms = lease_ttl_ms ? lease_ttl_ms : JJ_REMOTE_LEASE_TTL_MS; + state->state.revision = 1; + state->state.generation = 1; + state->state.mode = JJ_MODE_OFF; + state->state.configured_target_c = JJ_MANUAL_TARGET_DEFAULT_C; + state->state.active_authority = JJ_AUTHORITY_NONE; + state->state.control_inhibit = JJ_CONTROL_INHIBIT_NONE; + AUTHORITY_UNLOCK(); +} + +jj_control_result_t jj_authority_acquire( + jj_authority_t *state, + const jj_control_acquire_t *request, + uint64_t now_ms, + jj_control_snapshot_t *out) +{ + if (!state || !request || strlen(request->lease_id) != JJ_CONTROL_LEASE_ID_LEN || + request->owner[0] == '\0') + return JJ_CONTROL_INVALID; + AUTHORITY_LOCK(); + (void)lease_expire_unlocked(state, now_ms); + if (state->state.lease_active && + strcmp(state->state.lease_owner, request->owner) != 0 && + !request->takeover) { + snapshot_unlocked(state, now_ms, out); + AUTHORITY_UNLOCK(); + return JJ_CONTROL_AUTHORITY_REQUIRED; + } + const bool revoked_remote_demand = + state->state.mode == JJ_MODE_MANUAL && + state->state.manual_demand_authorized; + state->state.manual_demand_authorized = false; + state->state.lease_active = true; + copy_text(state->state.lease_id, sizeof state->state.lease_id, + request->lease_id); + copy_text(state->state.lease_owner, sizeof state->state.lease_owner, + request->owner); + state->lease_deadline_ms = now_ms + state->lease_ttl_ms; + advance(&state->state.generation); + advance(&state->state.revision); + if (state->state.mode != JJ_MODE_AUTOMATIC) { + state->state.active_authority = JJ_AUTHORITY_REACQUIRING; + state->state.control_inhibit = + JJ_CONTROL_INHIBIT_STATE_REFRESH_REQUIRED; + } + if (revoked_remote_demand || request->takeover) { + state->state.last_control_loss_ms = now_ms; + copy_text(state->state.last_control_loss_reason, + sizeof state->state.last_control_loss_reason, + request->takeover ? "explicit_takeover" : "remote_reacquired"); + } + snapshot_unlocked(state, now_ms, out); + AUTHORITY_UNLOCK(); + return JJ_CONTROL_OK; +} + +jj_control_result_t jj_authority_heartbeat( + jj_authority_t *state, + const char *lease_id, + uint32_t generation, + uint64_t now_ms, + jj_control_snapshot_t *out) +{ + if (!state || !lease_id) return JJ_CONTROL_INVALID; + AUTHORITY_LOCK(); + const bool expired = lease_expire_unlocked(state, now_ms); + if (generation != state->state.generation) { + snapshot_unlocked(state, now_ms, out); + AUTHORITY_UNLOCK(); + return expired ? JJ_CONTROL_AUTHORITY_LOST : JJ_CONTROL_GENERATION_STALE; + } + if (!state->state.lease_active || + strcmp(lease_id, state->state.lease_id) != 0) { + snapshot_unlocked(state, now_ms, out); + AUTHORITY_UNLOCK(); + return JJ_CONTROL_AUTHORITY_LOST; + } + state->lease_deadline_ms = now_ms + state->lease_ttl_ms; + snapshot_unlocked(state, now_ms, out); + AUTHORITY_UNLOCK(); + return JJ_CONTROL_OK; +} + +jj_control_result_t jj_authority_refresh( + jj_authority_t *state, + const char *lease_id, + uint32_t generation, + uint64_t now_ms, + jj_control_snapshot_t *out) +{ + if (!state || !lease_id) return JJ_CONTROL_INVALID; + AUTHORITY_LOCK(); + const bool expired = lease_expire_unlocked(state, now_ms); + if (generation != state->state.generation) { + snapshot_unlocked(state, now_ms, out); + AUTHORITY_UNLOCK(); + return expired ? JJ_CONTROL_AUTHORITY_LOST : JJ_CONTROL_GENERATION_STALE; + } + if (!state->state.lease_active || + strcmp(lease_id, state->state.lease_id) != 0) { + snapshot_unlocked(state, now_ms, out); + AUTHORITY_UNLOCK(); + return JJ_CONTROL_AUTHORITY_LOST; + } + state->lease_deadline_ms = now_ms + state->lease_ttl_ms; + if (state->state.active_authority == JJ_AUTHORITY_REACQUIRING) { + state->state.active_authority = JJ_AUTHORITY_NONE; + state->state.control_inhibit = state->state.mode == JJ_MODE_MANUAL + ? JJ_CONTROL_INHIBIT_NO_AUTHORITY : JJ_CONTROL_INHIBIT_NONE; + advance(&state->state.revision); + } + snapshot_unlocked(state, now_ms, out); + AUTHORITY_UNLOCK(); + return JJ_CONTROL_OK; +} + +static bool sensors_valid(const jj_inputs_t *input) +{ + return input->chamber.status == JJ_SENSOR_OK && + isfinite(input->chamber.temperature_c) && + input->outlet.status == JJ_SENSOR_OK && + isfinite(input->outlet.temperature_c) && + input->case_sensor.status == JJ_SENSOR_OK && + isfinite(input->case_sensor.temperature_c); +} + +static bool base_heat_eligible( + const jj_interlock_t *interlock, + const jj_inputs_t *input) +{ + const jj_outputs_t output = jj_interlock_snapshot(interlock); + return input->commissioned && sensors_valid(input) && + !input->overtemperature_detected && + output.fault == JJ_FAULT_NONE; +} + +static uint64_t request_signature(const jj_control_mutation_t *request) +{ + uint64_t hash = UINT64_C(1469598103934665603); + const unsigned char *text = (const unsigned char *)request->lease_id; + while (*text) { + hash ^= *text++; + hash *= UINT64_C(1099511628211); + } + const uint32_t values[] = { + request->generation, + request->expected_revision, + (uint32_t)request->kind, + }; + for (size_t i = 0; i < sizeof values / sizeof values[0]; ++i) { + for (unsigned shift = 0; shift < 32; shift += 8) { + hash ^= (values[i] >> shift) & UINT32_C(0xff); + hash *= UINT64_C(1099511628211); + } + } + uint32_t target_bits = 0; + memcpy(&target_bits, &request->target_c, sizeof target_bits); + for (unsigned shift = 0; shift < 32; shift += 8) { + hash ^= (target_bits >> shift) & UINT32_C(0xff); + hash *= UINT64_C(1099511628211); + } + return hash; +} + +static void cache_result( + jj_authority_t *state, + const jj_control_mutation_t *request, + uint64_t signature, + jj_control_result_t result, + uint64_t now_ms, + jj_control_snapshot_t *out) +{ + if (request->request_id[0]) { + jj_control_request_cache_entry_t *entry = + &state->request_cache[state->next_request_cache_entry]; + state->next_request_cache_entry = + (uint8_t)((state->next_request_cache_entry + 1U) % + JJ_CONTROL_REQUEST_CACHE_SIZE); + copy_text(entry->request_id, sizeof entry->request_id, + request->request_id); + entry->signature = signature; + entry->result = result; + } + snapshot_unlocked(state, now_ms, out); +} + +jj_control_result_t jj_authority_mutate( + jj_authority_t *state, + jj_interlock_t *interlock, + const jj_control_mutation_t *request, + const jj_inputs_t *authoritative_inputs, + uint64_t now_ms, + jj_control_snapshot_t *out) +{ + if (!state || !interlock || !request || !authoritative_inputs) + return JJ_CONTROL_INVALID; + const uint64_t signature = request_signature(request); + AUTHORITY_LOCK(); + const bool expired = lease_expire_unlocked(state, now_ms); + if (request->request_id[0]) { + for (size_t i = 0; i < JJ_CONTROL_REQUEST_CACHE_SIZE; ++i) { + const jj_control_request_cache_entry_t *entry = + &state->request_cache[i]; + if (strcmp(request->request_id, entry->request_id) != 0) continue; + if (signature != entry->signature) { + snapshot_unlocked(state, now_ms, out); + AUTHORITY_UNLOCK(); + return JJ_CONTROL_REQUEST_CONFLICT; + } + snapshot_unlocked(state, now_ms, out); + const jj_control_result_t cached = entry->result; + AUTHORITY_UNLOCK(); + return cached; + } + } + if (request->generation != state->state.generation) { + const jj_control_result_t result = expired + ? JJ_CONTROL_AUTHORITY_LOST : JJ_CONTROL_GENERATION_STALE; + cache_result(state, request, signature, result, now_ms, out); + AUTHORITY_UNLOCK(); + return result; + } + if (!state->state.lease_active || + strcmp(request->lease_id, state->state.lease_id) != 0) { + cache_result(state, request, signature, JJ_CONTROL_AUTHORITY_LOST, + now_ms, out); + AUTHORITY_UNLOCK(); + return JJ_CONTROL_AUTHORITY_LOST; + } + if (request->expected_revision != state->state.revision) { + cache_result(state, request, signature, JJ_CONTROL_REVISION_CONFLICT, + now_ms, out); + AUTHORITY_UNLOCK(); + return JJ_CONTROL_REVISION_CONFLICT; + } + if (state->state.active_authority == JJ_AUTHORITY_REACQUIRING) { + cache_result(state, request, signature, + JJ_CONTROL_AUTHORITY_REQUIRED, now_ms, out); + AUTHORITY_UNLOCK(); + return JJ_CONTROL_AUTHORITY_REQUIRED; + } + + jj_control_result_t result = JJ_CONTROL_OK; + if (request->kind == JJ_MUTATION_OFF) { + state->state.mode = JJ_MODE_OFF; + state->state.manual_demand_authorized = false; + state->state.active_authority = JJ_AUTHORITY_NONE; + state->state.control_inhibit = JJ_CONTROL_INHIBIT_NONE; + } else if (request->kind == JJ_MUTATION_MANUAL) { + const jj_outputs_t output = jj_interlock_snapshot(interlock); + if (output.fault != JJ_FAULT_NONE) { + result = JJ_CONTROL_HARDWARE_FAULT; + } else if (!base_heat_eligible(interlock, authoritative_inputs) || + authoritative_inputs->fan_proof != JJ_FAN_PROOF_PROVEN || + !isfinite(request->target_c) || + request->target_c < JJ_MANUAL_TARGET_MIN_C || + request->target_c > JJ_MANUAL_TARGET_MAX_C) { + result = JJ_CONTROL_REQUEST_INELIGIBLE; + } else { + state->state.mode = JJ_MODE_MANUAL; + state->state.configured_target_c = request->target_c; + state->state.manual_demand_authorized = true; + state->state.active_authority = JJ_AUTHORITY_REMOTE; + state->state.control_inhibit = JJ_CONTROL_INHIBIT_NONE; + } + } else if (request->kind == JJ_MUTATION_AUTOMATIC) { + const jj_outputs_t output = jj_interlock_snapshot(interlock); + if (output.fault != JJ_FAULT_NONE) { + result = JJ_CONTROL_HARDWARE_FAULT; + } else if (!base_heat_eligible(interlock, authoritative_inputs) || + authoritative_inputs->fan_proof != JJ_FAN_PROOF_PROVEN || + !authoritative_inputs->printer.online || + !authoritative_inputs->printer.printing || + !authoritative_inputs->automatic_target_available || + !isfinite(authoritative_inputs->automatic_target_c) || + authoritative_inputs->automatic_target_c <= 0.0f) { + result = JJ_CONTROL_REQUEST_INELIGIBLE; + } else { + state->state.mode = JJ_MODE_AUTOMATIC; + state->state.manual_demand_authorized = false; + state->state.active_authority = JJ_AUTHORITY_AUTOMATIC; + state->state.control_inhibit = JJ_CONTROL_INHIBIT_NONE; + } + } else if (request->kind == JJ_MUTATION_CLEAR_FAULT) { + if (state->state.mode != JJ_MODE_OFF) { + result = JJ_CONTROL_REQUEST_INELIGIBLE; + } else { + jj_inputs_t clear_inputs = *authoritative_inputs; + clear_inputs.mode = JJ_MODE_OFF; + if (!jj_interlock_clear_fault(interlock, &clear_inputs)) + result = JJ_CONTROL_HARDWARE_FAULT; + } + } else { + result = JJ_CONTROL_INVALID; + } + + if (result == JJ_CONTROL_OK) advance(&state->state.revision); + cache_result(state, request, signature, result, now_ms, out); + AUTHORITY_UNLOCK(); + return result; +} + +bool jj_authority_tick(jj_authority_t *state, uint64_t now_ms) +{ + if (!state) return false; + AUTHORITY_LOCK(); + const bool changed = lease_expire_unlocked(state, now_ms); + AUTHORITY_UNLOCK(); + return changed; +} + +void jj_authority_snapshot( + const jj_authority_t *state, + uint64_t now_ms, + jj_control_snapshot_t *out) +{ + if (!state || !out) return; + AUTHORITY_LOCK(); + snapshot_unlocked(state, now_ms, out); + AUTHORITY_UNLOCK(); +} + +void jj_authority_apply_to_inputs( + const jj_control_snapshot_t *authority, + jj_inputs_t *inputs) +{ + if (!authority || !inputs) return; + inputs->mode = authority->mode; + inputs->manual_target_c = authority->configured_target_c; + inputs->active_authority = authority->active_authority; + inputs->control_inhibit = authority->control_inhibit; + inputs->manual_demand_authorized = authority->manual_demand_authorized; +} + +const char *jj_control_result_str(jj_control_result_t result) +{ + switch (result) { + case JJ_CONTROL_OK: return "ok"; + case JJ_CONTROL_AUTHORITY_REQUIRED: return "control_authority_required"; + case JJ_CONTROL_AUTHORITY_LOST: return "control_authority_lost"; + case JJ_CONTROL_GENERATION_STALE: return "control_generation_stale"; + case JJ_CONTROL_REVISION_CONFLICT: return "state_revision_conflict"; + case JJ_CONTROL_REQUEST_INELIGIBLE: return "control_request_ineligible"; + case JJ_CONTROL_REQUEST_CONFLICT: return "control_request_conflict"; + case JJ_CONTROL_HARDWARE_FAULT: return "hardware_fault_latched"; + case JJ_CONTROL_INVALID: return "invalid_request"; + default: return "unknown"; + } +} + +const char *jj_mode_str(jj_mode_t mode) +{ + switch (mode) { + case JJ_MODE_OFF: return "off"; + case JJ_MODE_MANUAL: return "manual"; + case JJ_MODE_AUTOMATIC: return "automatic"; + default: return "unknown"; + } +} diff --git a/components/jj_interlock/include/jj_interlock.h b/components/jj_interlock/include/jj_interlock.h index 32bce1e..83605b5 100644 --- a/components/jj_interlock/include/jj_interlock.h +++ b/components/jj_interlock/include/jj_interlock.h @@ -8,6 +8,23 @@ #define JJ_MANUAL_TARGET_MAX_C 50.0f typedef enum { JJ_MODE_OFF = 0, JJ_MODE_MANUAL, JJ_MODE_AUTOMATIC } jj_mode_t; +typedef enum { + JJ_AUTHORITY_NONE = 0, + JJ_AUTHORITY_REMOTE, + JJ_AUTHORITY_AUTOMATIC, + JJ_AUTHORITY_REACQUIRING, +} jj_control_authority_t; +typedef enum { + JJ_CONTROL_INHIBIT_NONE = 0, + JJ_CONTROL_INHIBIT_NO_AUTHORITY, + JJ_CONTROL_INHIBIT_STATE_REFRESH_REQUIRED, + JJ_CONTROL_INHIBIT_NOT_ELIGIBLE, +} jj_control_inhibit_t; +typedef enum { + JJ_THERMAL_IDLE = 0, + JJ_THERMAL_HEATING, + JJ_THERMAL_COOLDOWN, +} jj_thermal_state_t; typedef enum { JJ_SENSOR_UNAVAILABLE = 0, JJ_SENSOR_OK, JJ_SENSOR_OPEN, JJ_SENSOR_SHORT, JJ_SENSOR_IMPLAUSIBLE, @@ -26,7 +43,8 @@ typedef enum { JJ_BLOCK_FAULT_LATCHED, JJ_BLOCK_PRINTER_UNAVAILABLE, JJ_BLOCK_PRINTER_NOT_PRINTING, JJ_BLOCK_AUTO_POLICY_UNAVAILABLE, JJ_BLOCK_MANUAL_TARGET_INVALID, JJ_BLOCK_FAN_PROOF_PENDING, - JJ_BLOCK_INVALID_MODE, + JJ_BLOCK_INVALID_MODE, JJ_BLOCK_CONTROL_NO_AUTHORITY, + JJ_BLOCK_STATE_REFRESH_REQUIRED, JJ_BLOCK_AUTO_AUTHORITY_UNAVAILABLE, } jj_block_reason_t; typedef struct { jj_sensor_status_t status; float temperature_c; } jj_sensor_sample_t; @@ -39,6 +57,11 @@ typedef struct { bool commissioned; jj_mode_t mode; float manual_target_c; + jj_control_authority_t active_authority; + jj_control_inhibit_t control_inhibit; + bool manual_demand_authorized; + bool automatic_target_available; + float automatic_target_c; jj_sensor_sample_t chamber; jj_sensor_sample_t outlet; jj_sensor_sample_t case_sensor; @@ -53,6 +76,9 @@ typedef struct { uint8_t fan_percent; float effective_target_c; bool thermal_management_required; + jj_control_authority_t active_authority; + jj_control_inhibit_t control_inhibit; + jj_thermal_state_t thermal_state; jj_fault_t fault; jj_block_reason_t block_reason; } jj_outputs_t; @@ -65,3 +91,6 @@ bool jj_interlock_clear_fault(jj_interlock_t *state, const jj_inputs_t *input); jj_outputs_t jj_interlock_snapshot(const jj_interlock_t *state); const char *jj_fault_str(jj_fault_t fault); 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); diff --git a/components/jj_interlock/jj_interlock.c b/components/jj_interlock/jj_interlock.c index 9467478..2bcd38f 100644 --- a/components/jj_interlock/jj_interlock.c +++ b/components/jj_interlock/jj_interlock.c @@ -37,6 +37,8 @@ jj_inputs_t jj_inputs_safe_defaults(void) .commissioned = false, .mode = JJ_MODE_OFF, .manual_target_c = JJ_MANUAL_TARGET_DEFAULT_C, + .active_authority = JJ_AUTHORITY_NONE, + .control_inhibit = JJ_CONTROL_INHIBIT_NONE, .chamber = {.status = JJ_SENSOR_UNAVAILABLE}, .outlet = {.status = JJ_SENSOR_UNAVAILABLE}, .case_sensor = {.status = JJ_SENSOR_UNAVAILABLE}, @@ -56,6 +58,18 @@ void jj_interlock_init(jj_interlock_t *state) static jj_outputs_t blocked(jj_interlock_t *state, jj_block_reason_t reason, const jj_inputs_t *input) { + jj_control_inhibit_t control_inhibit = input->control_inhibit; + if (reason == JJ_BLOCK_CONTROL_NO_AUTHORITY) + control_inhibit = JJ_CONTROL_INHIBIT_NO_AUTHORITY; + else if (reason == JJ_BLOCK_STATE_REFRESH_REQUIRED) + control_inhibit = JJ_CONTROL_INHIBIT_STATE_REFRESH_REQUIRED; + else if (reason == JJ_BLOCK_PRINTER_UNAVAILABLE || + reason == JJ_BLOCK_PRINTER_NOT_PRINTING || + reason == JJ_BLOCK_AUTO_POLICY_UNAVAILABLE || + reason == JJ_BLOCK_AUTO_AUTHORITY_UNAVAILABLE || + reason == JJ_BLOCK_MANUAL_TARGET_INVALID || + reason == JJ_BLOCK_FAN_PROOF_PENDING) + control_inhibit = JJ_CONTROL_INHIBIT_NOT_ELIGIBLE; const bool thermal_management = input->cooldown_required || input->fault_requires_thermal_management || state->fault_latched == JJ_FAULT_FAN || reason == JJ_BLOCK_FAN_PROOF_PENDING; @@ -64,6 +78,9 @@ static jj_outputs_t blocked(jj_interlock_t *state, jj_block_reason_t reason, .fan_percent = thermal_management ? 100 : 0, .effective_target_c = 0.0f, .thermal_management_required = thermal_management, + .active_authority = input->active_authority, + .control_inhibit = control_inhibit, + .thermal_state = thermal_management ? JJ_THERMAL_COOLDOWN : JJ_THERMAL_IDLE, .fault = state->fault_latched, .block_reason = reason, }; @@ -84,33 +101,59 @@ static jj_outputs_t step_unlocked(jj_interlock_t *state, const jj_inputs_t *inpu 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); 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); + /* dc_prusa owns its 15 s freshness decision. No second timer lives here. */ + if (!input->automatic_target_available || + !isfinite(input->automatic_target_c) || + input->automatic_target_c <= 0.0f) + return blocked(state, JJ_BLOCK_AUTO_POLICY_UNAVAILABLE, input); + const jj_outputs_t output = { + .heater_requested = true, + .fan_percent = 100, + .effective_target_c = input->automatic_target_c, + .thermal_management_required = true, + .active_authority = JJ_AUTHORITY_AUTOMATIC, + .control_inhibit = JJ_CONTROL_INHIBIT_NONE, + .thermal_state = JJ_THERMAL_HEATING, + .fault = JJ_FAULT_NONE, + .block_reason = JJ_BLOCK_NONE, + }; + state->last_output = output; + return output; } + 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); - 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); 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, }; @@ -187,6 +230,41 @@ const char *jj_block_reason_str(jj_block_reason_t reason) case JJ_BLOCK_MANUAL_TARGET_INVALID: return "manual_target_invalid"; case JJ_BLOCK_FAN_PROOF_PENDING: return "fan_proof_pending"; case JJ_BLOCK_INVALID_MODE: return "invalid_mode"; + 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"; + default: return "unknown"; + } +} + +const char *jj_control_authority_str(jj_control_authority_t authority) +{ + switch (authority) { + case JJ_AUTHORITY_NONE: return "none"; + case JJ_AUTHORITY_REMOTE: return "remote"; + case JJ_AUTHORITY_AUTOMATIC: return "automatic"; + case JJ_AUTHORITY_REACQUIRING: return "reacquiring"; + default: return "unknown"; + } +} + +const char *jj_control_inhibit_str(jj_control_inhibit_t inhibit) +{ + switch (inhibit) { + case JJ_CONTROL_INHIBIT_NONE: return "none"; + case JJ_CONTROL_INHIBIT_NO_AUTHORITY: return "no_authority"; + case JJ_CONTROL_INHIBIT_STATE_REFRESH_REQUIRED: return "state_refresh_required"; + case JJ_CONTROL_INHIBIT_NOT_ELIGIBLE: return "not_eligible"; + default: return "unknown"; + } +} + +const char *jj_thermal_state_str(jj_thermal_state_t state) +{ + switch (state) { + case JJ_THERMAL_IDLE: return "idle"; + case JJ_THERMAL_HEATING: return "heating"; + case JJ_THERMAL_COOLDOWN: return "cooldown"; default: return "unknown"; } } diff --git a/components/jj_portal/CMakeLists.txt b/components/jj_portal/CMakeLists.txt index 578bb6d..722f566 100644 --- a/components/jj_portal/CMakeLists.txt +++ b/components/jj_portal/CMakeLists.txt @@ -1,6 +1,7 @@ idf_component_register( SRCS "jj_portal.c" INCLUDE_DIRS "include" - REQUIRES jj_identity jj_interlock dc_portal dc_prusa dc_wifi json esp_http_server app_update nvs_flash + REQUIRES jj_authority jj_identity jj_interlock dc_portal dc_prusa dc_wifi json esp_http_server app_update nvs_flash esp_timer esp_hw_support + EMBED_TXTFILES "web/control.html" "web/control-client.js" ) target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/components/jj_portal/include/jj_portal.h b/components/jj_portal/include/jj_portal.h index 908c24b..6238af4 100644 --- a/components/jj_portal/include/jj_portal.h +++ b/components/jj_portal/include/jj_portal.h @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later #pragma once #include "esp_err.h" +#include "jj_authority.h" #include "jj_interlock.h" -esp_err_t jj_portal_start(jj_interlock_t *interlock); +esp_err_t jj_portal_start(jj_interlock_t *interlock, jj_authority_t *authority); diff --git a/components/jj_portal/jj_portal.c b/components/jj_portal/jj_portal.c index a0d49d3..b36e2ac 100644 --- a/components/jj_portal/jj_portal.c +++ b/components/jj_portal/jj_portal.c @@ -8,14 +8,56 @@ #include "esp_http_server.h" #include "esp_log.h" #include "esp_mac.h" +#include "esp_random.h" +#include "esp_timer.h" #include +#include #include #include #include +#define JJ_CONTROL_BODY_MAX 2048 + static const char *TAG = "jj_portal"; static jj_interlock_t *s_interlock; +static jj_authority_t *s_authority; static char s_device_id[32]; +static char s_boot_id[33]; + +extern const unsigned char _binary_web_control_html_start[]; +extern const unsigned char _binary_web_control_html_end[]; +extern const unsigned char _binary_web_control_client_js_start[]; +extern const unsigned char _binary_web_control_client_js_end[]; + +static uint64_t monotonic_ms(void) +{ + return (uint64_t)esp_timer_get_time() / 1000U; +} + +static void random_token(char output[33]) +{ + static const char digits[] = "0123456789abcdef"; + uint8_t bytes[16]; + esp_fill_random(bytes, sizeof bytes); + for (size_t i = 0; i < sizeof bytes; ++i) { + output[i * 2] = digits[bytes[i] >> 4]; + output[i * 2 + 1] = digits[bytes[i] & 0x0f]; + } + output[32] = '\0'; +} + +static jj_inputs_t authoritative_inputs(const dc_prusa_status_t *printer) +{ + jj_inputs_t input = jj_inputs_safe_defaults(); + if (printer) { + input.printer.online = printer->online; + input.printer.printing = printer->online && + strcmp(printer->printer_state, "PRINTING") == 0; + input.printer.bed_target_c = printer->bed_target; + } + /* Hardware commissioning and automatic target mapping remain unavailable. */ + return input; +} static esp_err_t send_json(httpd_req_t *req, cJSON *json) { @@ -29,6 +71,95 @@ static esp_err_t send_json(httpd_req_t *req, cJSON *json) return err; } +static esp_err_t send_error( + httpd_req_t *req, + const char *status, + jj_control_result_t result, + cJSON *state) +{ + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "error", jj_control_result_str(result)); + if (state) cJSON_AddItemToObject(root, "state", state); + httpd_resp_set_status(req, status); + return send_json(req, root); +} + +static cJSON *read_json_body(httpd_req_t *req) +{ + char content_type[48]; + if (httpd_req_get_hdr_value_str(req, "Content-Type", content_type, + sizeof content_type) != ESP_OK || + strncmp(content_type, "application/json", 16) != 0 || + (content_type[16] != '\0' && content_type[16] != ';' && + content_type[16] != ' ' && content_type[16] != '\t')) + return NULL; + if (req->content_len <= 0 || req->content_len > JJ_CONTROL_BODY_MAX) + return NULL; + char *body = malloc((size_t)req->content_len + 1); + if (!body) return NULL; + size_t received = 0; + while (received < (size_t)req->content_len) { + const int count = httpd_req_recv( + req, body + received, (size_t)req->content_len - received); + if (count <= 0) { + free(body); + return NULL; + } + received += (size_t)count; + } + body[received] = '\0'; + cJSON *json = cJSON_ParseWithLength(body, received); + free(body); + return json; +} + +static bool json_u32(const cJSON *root, const char *name, uint32_t *out) +{ + const cJSON *item = cJSON_GetObjectItemCaseSensitive(root, name); + if (!cJSON_IsNumber(item) || !isfinite(item->valuedouble) || + item->valuedouble < 0 || item->valuedouble > UINT32_MAX || + item->valuedouble != (double)(uint32_t)item->valuedouble) + return false; + *out = (uint32_t)item->valuedouble; + return true; +} + +static bool json_text( + const cJSON *root, + const char *name, + char *out, + size_t out_size, + bool required) +{ + const cJSON *item = cJSON_GetObjectItemCaseSensitive(root, name); + if (!item && !required) { + out[0] = '\0'; + return true; + } + if (!cJSON_IsString(item) || !item->valuestring || + strlen(item->valuestring) >= out_size || + (required && item->valuestring[0] == '\0')) + return false; + snprintf(out, out_size, "%s", item->valuestring); + return true; +} + +static bool valid_request_id(const char *value) +{ + if (!value || value[0] == '\0') return true; + if (strlen(value) != 36) return false; + for (size_t i = 0; i < 36; ++i) { + if (i == 8 || i == 13 || i == 18 || i == 23) { + if (value[i] != '-') return false; + } else if (!((value[i] >= '0' && value[i] <= '9') || + (value[i] >= 'a' && value[i] <= 'f') || + (value[i] >= 'A' && value[i] <= 'F'))) { + return false; + } + } + return true; +} + static esp_err_t info_get(httpd_req_t *req) { cJSON *root = cJSON_CreateObject(); @@ -40,55 +171,120 @@ static esp_err_t info_get(httpd_req_t *req) cJSON_AddNumberToObject(ui, "schema", 1); cJSON_AddStringToObject(ui, "product", JJ_IDENTITY_PRODUCT_ID); cJSON_AddStringToObject(ui, "display_name", JJ_IDENTITY_DISPLAY_NAME); + cJSON_AddStringToObject(ui, "control_path", "/control"); cJSON *capabilities = cJSON_AddArrayToObject(root, "capabilities"); cJSON_AddItemToArray(capabilities, cJSON_CreateString("source_status")); cJSON_AddItemToArray(capabilities, cJSON_CreateString("polling")); + cJSON_AddItemToArray(capabilities, cJSON_CreateString("control_authority_v1")); + cJSON_AddItemToArray(capabilities, cJSON_CreateString("atomic_mutations")); + cJSON_AddItemToArray(capabilities, cJSON_CreateString("explicit_takeover")); return send_json(req, root); } -static esp_err_t state_get(httpd_req_t *req) +static cJSON *control_state_json( + const jj_control_snapshot_t *authority, + const jj_outputs_t *output, + const dc_prusa_status_t *printer, + bool include_lease_id) { - dc_prusa_status_t printer = {0}; - (void)dc_prusa_get_status(&printer); - const jj_outputs_t output = jj_interlock_snapshot(s_interlock); cJSON *root = cJSON_CreateObject(); cJSON_AddNumberToObject(root, "api_version", 2); - cJSON_AddStringToObject(root, "mode", "off"); - cJSON_AddStringToObject(root, "controller", "disabled"); + cJSON_AddStringToObject(root, "boot_id", s_boot_id); + cJSON_AddNumberToObject(root, "state_revision", authority->revision); + cJSON_AddStringToObject(root, "mode", jj_mode_str(authority->mode)); + const char *controller = authority->active_authority == JJ_AUTHORITY_REMOTE + ? "remote" : + authority->active_authority == JJ_AUTHORITY_AUTOMATIC ? "prusalink" : + authority->active_authority == JJ_AUTHORITY_REACQUIRING + ? "reacquiring" : "none"; + cJSON_AddStringToObject(root, "controller", controller); cJSON *control = cJSON_AddObjectToObject(root, "control"); - cJSON_AddStringToObject(control, "source", "unavailable"); - cJSON_AddNullToObject(control, "temperature_c"); - cJSON_AddNumberToObject(root, "target_c", JJ_MANUAL_TARGET_DEFAULT_C); + cJSON_AddStringToObject(control, "authority", + jj_control_authority_str(authority->active_authority)); + cJSON_AddStringToObject(control, "inhibit", + jj_control_inhibit_str(output->control_inhibit)); + cJSON_AddNumberToObject(control, "generation", authority->generation); + cJSON_AddNumberToObject(control, "expires_in_ms", + authority->lease_expires_in_ms); + cJSON_AddBoolToObject(control, "lease_active", authority->lease_active); + cJSON_AddStringToObject(control, "lease_owner", authority->lease_owner); + if (include_lease_id) + cJSON_AddStringToObject(control, "lease_id", authority->lease_id); + if (authority->last_control_loss_ms) { + cJSON_AddNumberToObject(control, "last_loss_monotonic_ms", + (double)authority->last_control_loss_ms); + cJSON_AddStringToObject(control, "last_loss_reason", + authority->last_control_loss_reason); + } + cJSON_AddNumberToObject(root, "configured_target_c", + authority->configured_target_c); + cJSON_AddNumberToObject(root, "target_c", authority->configured_target_c); + if (output->effective_target_c > 0.0f) + cJSON_AddNumberToObject(root, "effective_target_c", + output->effective_target_c); + else + cJSON_AddNullToObject(root, "effective_target_c"); cJSON *heater = cJSON_AddObjectToObject(root, "heater"); - cJSON_AddBoolToObject(heater, "requested", false); - cJSON_AddBoolToObject(heater, "allowed", false); + 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_AddBoolToObject(heater, "delivered", false); cJSON_AddNumberToObject(heater, "delivered_percent", 0); cJSON_AddBoolToObject(heater, "available", false); cJSON *fan = cJSON_AddObjectToObject(root, "fan"); - cJSON_AddNumberToObject(fan, "requested_percent", output.fan_percent); + cJSON_AddNumberToObject(fan, "requested_percent", output->fan_percent); cJSON_AddNumberToObject(fan, "delivered_percent", 0); cJSON_AddBoolToObject(fan, "available", false); + cJSON_AddStringToObject(root, "thermal_state", + jj_thermal_state_str(output->thermal_state)); cJSON_AddStringToObject(root, "dominant_constraint", - jj_block_reason_str(output.block_reason)); + jj_block_reason_str(output->block_reason)); cJSON *safety = cJSON_AddObjectToObject(root, "safety"); - cJSON_AddStringToObject(safety, "health", "degraded"); + cJSON_AddStringToObject(safety, "health", + output->fault == JJ_FAULT_NONE ? "degraded" : "fault"); cJSON_AddBoolToObject(safety, "commissioned", false); - cJSON_AddBoolToObject(safety, "fault_latched", output.fault != JJ_FAULT_NONE); - cJSON_AddStringToObject(safety, "fault", jj_fault_str(output.fault)); - cJSON_AddStringToObject(safety, "interlock", jj_block_reason_str(output.block_reason)); + cJSON_AddBoolToObject(safety, "fault_latched", + output->fault != JJ_FAULT_NONE); + cJSON_AddStringToObject(safety, "fault", jj_fault_str(output->fault)); + cJSON_AddStringToObject(safety, "interlock", + jj_block_reason_str(output->block_reason)); cJSON *prusa = cJSON_AddObjectToObject(root, "printer"); cJSON_AddStringToObject(prusa, "source", "prusalink"); - cJSON_AddStringToObject(prusa, "connection", dc_prusa_state_str(printer.state)); - cJSON_AddStringToObject(prusa, "state", printer.printer_state); - if (printer.online) { - cJSON_AddNumberToObject(prusa, "bed_temperature_c", printer.bed_temp); - cJSON_AddNumberToObject(prusa, "bed_target_c", printer.bed_target); + cJSON_AddStringToObject(prusa, "connection", dc_prusa_state_str(printer->state)); + cJSON_AddStringToObject(prusa, "state", printer->printer_state); + if (printer->online) { + cJSON_AddNumberToObject(prusa, "bed_temperature_c", printer->bed_temp); + cJSON_AddNumberToObject(prusa, "bed_target_c", printer->bed_target); } else { cJSON_AddNullToObject(prusa, "bed_temperature_c"); cJSON_AddNullToObject(prusa, "bed_target_c"); } - return send_json(req, root); + return root; +} + +static void evaluate_state( + const jj_control_snapshot_t *authority, + const dc_prusa_status_t *printer, + jj_outputs_t *output) +{ + jj_inputs_t input = authoritative_inputs(printer); + jj_authority_apply_to_inputs(authority, &input); + *output = jj_interlock_step(s_interlock, &input); +} + +static esp_err_t state_get(httpd_req_t *req) +{ + const uint64_t now_ms = monotonic_ms(); + (void)jj_authority_tick(s_authority, now_ms); + jj_control_snapshot_t authority; + jj_authority_snapshot(s_authority, now_ms, &authority); + dc_prusa_status_t printer = {0}; + (void)dc_prusa_get_status(&printer); + jj_outputs_t output; + evaluate_state(&authority, &printer, &output); + return send_json(req, control_state_json( + &authority, &output, &printer, false)); } static esp_err_t health_get(httpd_req_t *req) @@ -99,13 +295,187 @@ static esp_err_t health_get(httpd_req_t *req) return send_json(req, root); } +static esp_err_t control_page_get(httpd_req_t *req) +{ + httpd_resp_set_type(req, "text/html; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + return httpd_resp_send(req, (const char *)_binary_web_control_html_start, + (uintptr_t)_binary_web_control_html_end - + (uintptr_t)_binary_web_control_html_start); +} + +static esp_err_t control_script_get(httpd_req_t *req) +{ + httpd_resp_set_type(req, "application/javascript; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + return httpd_resp_send(req, + (const char *)_binary_web_control_client_js_start, + (uintptr_t)_binary_web_control_client_js_end - + (uintptr_t)_binary_web_control_client_js_start); +} + +static const char *result_status(jj_control_result_t result) +{ + if (result == JJ_CONTROL_INVALID) return "400 Bad Request"; + if (result == JJ_CONTROL_HARDWARE_FAULT) return "423 Locked"; + return "409 Conflict"; +} + +static esp_err_t send_control_result( + httpd_req_t *req, + jj_control_result_t result, + const jj_control_snapshot_t *authority, + bool include_lease_id) +{ + dc_prusa_status_t printer = {0}; + (void)dc_prusa_get_status(&printer); + jj_outputs_t output; + evaluate_state(authority, &printer, &output); + cJSON *state = control_state_json( + authority, &output, &printer, include_lease_id); + if (result != JJ_CONTROL_OK) + return send_error(req, result_status(result), result, state); + return send_json(req, state); +} + +static esp_err_t acquire_post(httpd_req_t *req) +{ + cJSON *json = read_json_body(req); + jj_control_acquire_t request = {0}; + const cJSON *takeover = json + ? cJSON_GetObjectItemCaseSensitive(json, "takeover") : NULL; + if (!json || !json_text(json, "owner", request.owner, + sizeof request.owner, true) || + (takeover && !cJSON_IsBool(takeover))) { + cJSON_Delete(json); + return send_error(req, "400 Bad Request", JJ_CONTROL_INVALID, NULL); + } + request.takeover = cJSON_IsTrue(takeover); + random_token(request.lease_id); + cJSON_Delete(json); + jj_control_snapshot_t authority; + const jj_control_result_t result = jj_authority_acquire( + s_authority, &request, monotonic_ms(), &authority); + return send_control_result(req, result, &authority, + result == JJ_CONTROL_OK); +} + +static bool parse_lease_request( + const cJSON *json, + char lease_id[JJ_CONTROL_LEASE_ID_LEN + 1], + uint32_t *generation) +{ + return json && + json_text(json, "lease_id", lease_id, + JJ_CONTROL_LEASE_ID_LEN + 1, true) && + strlen(lease_id) == JJ_CONTROL_LEASE_ID_LEN && + json_u32(json, "control_generation", generation); +} + +static esp_err_t refresh_post(httpd_req_t *req) +{ + cJSON *json = read_json_body(req); + char lease_id[JJ_CONTROL_LEASE_ID_LEN + 1] = {0}; + uint32_t generation = 0; + if (!parse_lease_request(json, lease_id, &generation)) { + cJSON_Delete(json); + return send_error(req, "400 Bad Request", JJ_CONTROL_INVALID, NULL); + } + cJSON_Delete(json); + jj_control_snapshot_t authority; + const jj_control_result_t result = jj_authority_refresh( + s_authority, lease_id, generation, monotonic_ms(), &authority); + return send_control_result(req, result, &authority, + result == JJ_CONTROL_OK); +} + +static esp_err_t heartbeat_post(httpd_req_t *req) +{ + cJSON *json = read_json_body(req); + char lease_id[JJ_CONTROL_LEASE_ID_LEN + 1] = {0}; + uint32_t generation = 0; + if (!parse_lease_request(json, lease_id, &generation)) { + cJSON_Delete(json); + return send_error(req, "400 Bad Request", JJ_CONTROL_INVALID, NULL); + } + cJSON_Delete(json); + jj_control_snapshot_t authority; + const jj_control_result_t result = jj_authority_heartbeat( + s_authority, lease_id, generation, monotonic_ms(), &authority); + return send_control_result(req, result, &authority, + result == JJ_CONTROL_OK); +} + +static bool parse_mutation( + const cJSON *json, + jj_control_mutation_t *request) +{ + if (!parse_lease_request(json, request->lease_id, + &request->generation) || + !json_u32(json, "expected_state_revision", + &request->expected_revision) || + !json_text(json, "request_id", request->request_id, + sizeof request->request_id, false) || + !valid_request_id(request->request_id)) + return false; + const cJSON *action = cJSON_GetObjectItemCaseSensitive(json, "action"); + if (!cJSON_IsString(action) || !action->valuestring) return false; + if (strcmp(action->valuestring, "off") == 0) { + request->kind = JJ_MUTATION_OFF; + } else if (strcmp(action->valuestring, "manual") == 0) { + const cJSON *target = cJSON_GetObjectItemCaseSensitive(json, "target_c"); + if (!cJSON_IsNumber(target) || !isfinite(target->valuedouble)) + return false; + request->kind = JJ_MUTATION_MANUAL; + request->target_c = (float)target->valuedouble; + } else if (strcmp(action->valuestring, "automatic") == 0) { + request->kind = JJ_MUTATION_AUTOMATIC; + } else if (strcmp(action->valuestring, "clear_fault") == 0) { + request->kind = JJ_MUTATION_CLEAR_FAULT; + } else { + return false; + } + return true; +} + +static esp_err_t mutate_post(httpd_req_t *req) +{ + cJSON *json = read_json_body(req); + jj_control_mutation_t request = {0}; + if (!json || !parse_mutation(json, &request)) { + cJSON_Delete(json); + return send_error(req, "400 Bad Request", JJ_CONTROL_INVALID, NULL); + } + cJSON_Delete(json); + dc_prusa_status_t printer = {0}; + (void)dc_prusa_get_status(&printer); + const jj_inputs_t input = authoritative_inputs(&printer); + jj_control_snapshot_t authority; + const jj_control_result_t result = jj_authority_mutate( + s_authority, s_interlock, &request, &input, monotonic_ms(), + &authority); + return send_control_result(req, result, &authority, + result == JJ_CONTROL_OK); +} + static esp_err_t register_routes(httpd_handle_t server, void *ctx) { (void)ctx; const httpd_uri_t routes[] = { + {.uri = "/control", .method = HTTP_GET, .handler = control_page_get}, + {.uri = "/jumpjet-control.js", .method = HTTP_GET, + .handler = control_script_get}, {.uri = "/api/v2/info", .method = HTTP_GET, .handler = info_get}, {.uri = "/api/v2/state", .method = HTTP_GET, .handler = state_get}, {.uri = "/api/v2/health", .method = HTTP_GET, .handler = health_get}, + {.uri = "/api/v2/control/acquire", .method = HTTP_POST, + .handler = acquire_post}, + {.uri = "/api/v2/control/refresh", .method = HTTP_POST, + .handler = refresh_post}, + {.uri = "/api/v2/control/heartbeat", .method = HTTP_POST, + .handler = heartbeat_post}, + {.uri = "/api/v2/control/mutate", .method = HTTP_POST, + .handler = mutate_post}, }; for (size_t i = 0; i < sizeof(routes) / sizeof(routes[0]); ++i) { esp_err_t err = httpd_register_uri_handler(server, &routes[i]); @@ -246,16 +616,18 @@ static esp_err_t factory_reset(void *ctx) return dc_prusa_clear_config(); } -esp_err_t jj_portal_start(jj_interlock_t *interlock) +esp_err_t jj_portal_start(jj_interlock_t *interlock, jj_authority_t *authority) { - if (!interlock) return ESP_ERR_INVALID_ARG; + if (!interlock || !authority) return ESP_ERR_INVALID_ARG; s_interlock = interlock; + s_authority = authority; + random_token(s_boot_id); uint8_t mac[6] = {0}; esp_read_mac(mac, ESP_MAC_WIFI_STA); snprintf(s_device_id, sizeof(s_device_id), JJ_IDENTITY_DEVICE_ID_PREFIX "%02x%02x%02x", mac[3], mac[4], mac[5]); httpd_config_t http = HTTPD_DEFAULT_CONFIG(); - http.max_uri_handlers = 24; + http.max_uri_handlers = 32; http.lru_purge_enable = true; const dc_portal_config_t config = { .product = JJ_IDENTITY_PRODUCT_ID, diff --git a/components/jj_portal/web/control-client.js b/components/jj_portal/web/control-client.js new file mode 100644 index 0000000..967ccb6 --- /dev/null +++ b/components/jj_portal/web/control-client.js @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +(function (root, factory) { + const api = factory(); + if (typeof module === "object" && module.exports) module.exports = api; + else root.JumpJetControl = api; +})(typeof globalThis !== "undefined" ? globalThis : this, function () { + "use strict"; + + const CONTROL_LOSS_ERRORS = new Set([ + "control_authority_required", + "control_authority_lost", + "control_generation_stale", + ]); + + class ControlError extends Error { + constructor(code, state) { + super(code); + this.name = "ControlError"; + this.code = code; + this.state = state || null; + } + } + + function titleCase(value) { + return value ? value[0].toUpperCase() + value.slice(1) : "None"; + } + + function controllerLabel(state) { + const authority = state && state.control ? state.control.authority : "none"; + const controller = authority === "remote" ? "Remote" : + authority === "automatic" ? "PrusaLink" : + authority === "reacquiring" ? "Reacquiring" : "None"; + return titleCase(state && state.mode) + " · " + controller; + } + + function constraintMessage(code) { + const messages = { + none: "No active control constraint", + off: "Heating is off", + not_commissioned: "Heating unavailable: product not commissioned", + fault_latched: "Heating inhibited: safety fault latched", + printer_unavailable: "Heating inhibited: PrusaLink status unavailable or stale", + printer_not_printing: "Heating inhibited: printer is not printing", + automatic_policy_unavailable: "Heating inhibited: Automatic target policy unavailable", + manual_target_invalid: "Heating inhibited: Manual target is invalid", + fan_proof_pending: "Heating inhibited: airflow is not proven", + invalid_mode: "Heating inhibited: operating mode is invalid", + 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", + }; + return messages[code] || "Heating inhibited: see advanced diagnostics"; + } + + class Client { + constructor(options) { + this.fetch = options.fetch; + this.owner = options.owner; + this.onState = options.onState || function () {}; + this.onReady = options.onReady || function () {}; + this.onControlLost = options.onControlLost || function () {}; + this.uuid = options.uuid || function () { + return crypto.randomUUID(); + }; + this.state = null; + this.leaseId = ""; + this.generation = 0; + this.revision = 0; + this.ready = false; + this.resumeSequence = 0; + } + + invalidate(code) { + this.ready = false; + this.leaseId = ""; + this.onReady(false); + this.onControlLost(code); + } + + acceptState(state, options) { + if (!state || typeof state !== "object" || !state.control) return false; + const allowBootChange = options && options.allowBootChange; + const generation = Number(state.control.generation); + const revision = Number(state.state_revision); + if (!Number.isSafeInteger(generation) || !Number.isSafeInteger(revision)) + return false; + if (this.state && state.boot_id !== this.state.boot_id && !allowBootChange) + return false; + if (this.state && state.boot_id === this.state.boot_id && + (generation < this.generation || + (generation === this.generation && revision < this.revision))) + return false; + this.state = state; + this.generation = generation; + this.revision = revision; + this.onState(state); + return true; + } + + async post(path, body) { + const response = await this.fetch(path, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(body), + cache: "no-store", + }); + const payload = await response.json(); + if (!response.ok) throw new ControlError(payload.error || "request_failed", + payload.state); + return payload; + } + + async resume(takeover) { + const sequence = ++this.resumeSequence; + this.ready = false; + this.leaseId = ""; + this.onReady(false); + try { + const acquired = await this.post("/api/v2/control/acquire", { + owner: this.owner, + takeover: takeover === true, + }); + if (sequence !== this.resumeSequence) return null; + if (!this.acceptState(acquired, {allowBootChange: true})) + throw new ControlError("invalid_authoritative_state"); + this.leaseId = acquired.control.lease_id; + const refreshed = await this.post("/api/v2/control/refresh", { + lease_id: this.leaseId, + control_generation: this.generation, + }); + if (sequence !== this.resumeSequence) return null; + if (!this.acceptState(refreshed, {allowBootChange: true})) + throw new ControlError("invalid_authoritative_state"); + this.leaseId = refreshed.control.lease_id; + this.ready = true; + this.onReady(true); + return refreshed; + } catch (error) { + if (sequence === this.resumeSequence) { + if (error.state) this.acceptState(error.state, {allowBootChange: true}); + this.invalidate(error.code); + } + throw error; + } + } + + async mutate(action, targetC) { + if (!this.ready || !this.leaseId) + throw new ControlError("control_authority_required"); + const requestGeneration = this.generation; + const body = { + lease_id: this.leaseId, + control_generation: requestGeneration, + expected_state_revision: this.revision, + request_id: this.uuid(), + action: action, + }; + if (action === "manual") body.target_c = targetC; + try { + const state = await this.post("/api/v2/control/mutate", body); + if (requestGeneration !== this.generation) return this.state; + this.acceptState(state); + return state; + } catch (error) { + if (requestGeneration !== this.generation) return this.state; + if (error.state) this.acceptState(error.state); + if (CONTROL_LOSS_ERRORS.has(error.code)) this.invalidate(error.code); + throw error; + } + } + + async heartbeat() { + if (!this.ready || !this.leaseId) return null; + const requestGeneration = this.generation; + try { + const state = await this.post("/api/v2/control/heartbeat", { + lease_id: this.leaseId, + control_generation: requestGeneration, + }); + if (requestGeneration === this.generation) this.acceptState(state); + return state; + } catch (error) { + if (requestGeneration === this.generation && + CONTROL_LOSS_ERRORS.has(error.code)) this.invalidate(error.code); + throw error; + } + } + } + + return { + Client, + ControlError, + CONTROL_LOSS_ERRORS, + controllerLabel, + constraintMessage, + }; +}); diff --git a/components/jj_portal/web/control.html b/components/jj_portal/web/control.html new file mode 100644 index 0000000..16df72e --- /dev/null +++ b/components/jj_portal/web/control.html @@ -0,0 +1,136 @@ + + + + + + Jump Jet Control + + + +
+

Jump Jet

+

Reacquiring control…

+
+

Off · None

+

Loading authoritative state…

+
+
Configured target
—
+
Effective target
—
+
Heater request
—
+
Heater allowed
—
+
Heater delivered
—
+
Thermal handling
—
+
Health / fault
—
+
+
+
+

Control

+ +
+ + + + +
+ +

+
+
+

Advanced control details

+
—
+
+
+ + + + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 138db74..1b0589a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -15,7 +15,7 @@ safety decisions. See [PRODUCT_SAFETY_CONTRACT.md](PRODUCT_SAFETY_CONTRACT.md). ## Boot order 1. Initialize logging and NVS. -2. Initialize the interlock in OFF with physical actuation unavailable. +2. Initialize control authority and the interlock in OFF with physical actuation unavailable. 3. Start the control/safety task. 4. Start Wi-Fi, read-only PrusaLink polling, and the management portal. 5. Wait for a synchronized cold-safe interlock cycle. @@ -27,6 +27,36 @@ target is configuration, not permission to heat. ## Control and diagnostics +Operating mode, active authority, control inhibit, latched safety fault, and +thermal handling are independent dimensions. `jj_authority` owns remote leases, +generation/revision checks, explicit takeover, and complete mode/target +mutations. `jj_interlock` independently applies product safety vetoes and owns +the final logical heater permission. Physical delivery remains unavailable. + +MANUAL demand requires a live REMOTE lease. Expiry or takeover synchronously +removes that demand and advances the control generation without creating a +hardware fault. The configured mode and target may remain visible, while +cooldown/fault fan requests continue. Acquisition enters REACQUIRING; a separate +authoritative refresh handshake must complete before any mutation can commit, +and a new MANUAL request is required to resume logical heat. + +Once a validated transition into AUTOMATIC commits, product-local AUTOMATIC +authority owns demand. The browser lease may expire without changing that +authority. Fresh exact `PRINTING` status and every product safety/policy input +are still re-evaluated on each interlock cycle and any loss fails cold. + +All operator mutations carry an opaque lease ID, control generation, expected +state revision, a requested final semantic state, and optionally a request UUID. +Validation and commit occur in one authority critical section. Stable error +classes distinguish missing/lost authority, stale generations, revision +conflicts, ineligible requests, idempotency conflicts, and latched faults. + +The control page discards its local authority assumption on foreground/resume, +then acquires and refreshes before enabling controls. Visible-page heartbeats +are a usability mechanism only; server lease expiry is the fail-cold mechanism. +Responses are ordered by boot ID, generation, and revision so a late response +from an older authority generation cannot replace newer UI state. + Only the exact PrusaLink state `PRINTING` passes the AUTOMATIC state allowlist. Even then, AUTOMATIC remains cold because the bed-target-to-chamber-target policy is deliberately undefined. Manual targets outside 30–50 °C are rejected. diff --git a/docs/CONTROL_AUTHORITY.md b/docs/CONTROL_AUTHORITY.md new file mode 100644 index 0000000..f0ad6a8 --- /dev/null +++ b/docs/CONTROL_AUTHORITY.md @@ -0,0 +1,88 @@ +# Jump Jet control authority + +This is the control-authority and browser mutation contract. It does not grant +physical heater capability and does not replace the product safety contract. + +## Orthogonal state + +The API reports operating mode, active authority, control inhibit, safety fault, +and thermal state separately. A disconnected remote controller is represented +as `authority=none` and `inhibit=no_authority`; it is not a hardware fault. +Cooldown may therefore remain active while remote heat request and logical heat +permission are both off. + +MANUAL demand is valid only under REMOTE authority. AUTOMATIC demand is owned by +product-local AUTOMATIC authority after an eligible atomic transition and is +not tied to the browser lease. OFF has no heating authority. + +## Lease lifecycle + +`POST /api/v2/control/acquire` accepts an operator-readable `owner` and an +explicit `takeover` boolean. It returns a server-generated opaque lease ID, +control generation, state revision, expiry, current eligibility/constraint, +and the complete authoritative state. A different active owner is rejected +with `control_authority_required` unless takeover is explicitly true. + +Acquisition enters REACQUIRING and always clears prior MANUAL demand. The client +must then call `POST /api/v2/control/refresh` with the lease ID and generation. +Only that server-side handshake ends REACQUIRING; mutations sent before it are +rejected. This makes refresh an enforced protocol step rather than a browser +convention. The initial lease TTL is 60 seconds. Visible-page heartbeats renew +it for usability, but background JavaScript is not relied on for fail-cold +behavior. + +On foreground/resume the browser discards its authority assumption and repeats +acquire plus refresh. Reconnection never restores a prior MANUAL request. + +## Atomic mutations + +`POST /api/v2/control/mutate` carries: + +- `lease_id` +- `control_generation` +- `expected_state_revision` +- optional `request_id` UUID for bounded content-bound retry handling +- final semantic `action`: `off`, `manual`, `automatic`, or `clear_fault` +- `target_c` for `manual` + +The server checks lease ownership, generation, revision, current product inputs, +interlocks, fault state, fan proof, PrusaLink eligibility where applicable, and +transition policy in one authority critical section. It commits the complete +mutation or nothing and returns authoritative resulting state. Request IDs are +cached in a bounded eight-entry window; an identical retry returns its original +result with current authoritative state, while reuse with different content +returns `control_request_conflict`. Historical state is never replayed as live +control demand. + +Stable machine-readable failures are: + +| Code | Meaning | +|---|---| +| `control_authority_required` | No usable authority, explicit takeover required, or refresh incomplete | +| `control_authority_lost` | The presented lease is absent, revoked, or expired | +| `control_generation_stale` | The request belongs to an older authority epoch | +| `state_revision_conflict` | Authoritative state changed before commit | +| `control_request_ineligible` | Current product conditions reject the complete request | +| `control_request_conflict` | A request UUID was reused with different content | +| `hardware_fault_latched` | A real safety fault requires its existing recovery semantics | +| `invalid_request` | The request schema is invalid | + +Control conflicts use HTTP 409, hardware faults use 423, and malformed requests +use 400. Clients must use codes, not prose. + +## Client ordering + +State carries a boot ID, control generation, and revision. The control page +accepts a new boot ID only during explicit reacquisition, rejects regressive +generation/revision state, and ignores mutation responses whose request +generation is no longer current. A control-loss response disables all mutations +until another acquisition and refresh complete. + +The primary controller label is derived from active authority, never merely from +configured mode. Operator-facing constraints are translated into readable +guidance; raw inhibit and constraint codes remain available in advanced +diagnostics for tooling and troubleshooting. + +All control POST bodies must use `application/json`. General state GET responses +never disclose the opaque lease ID. The dedicated control page receives it only +from successful lease-bound responses. diff --git a/docs/PRODUCT_SAFETY_CONTRACT.md b/docs/PRODUCT_SAFETY_CONTRACT.md index abf86cf..712ff79 100644 --- a/docs/PRODUCT_SAFETY_CONTRACT.md +++ b/docs/PRODUCT_SAFETY_CONTRACT.md @@ -13,8 +13,44 @@ Cooldown or fault-related fan activity may continue while mode is OFF. The retained MANUAL target persists across mode changes and reboot. Its allowed range is 30–50 °C inclusive and its default is 45 °C. Inputs outside that range -are rejected, never clamped. The foundation models these semantics but exposes -no mode/target mutation yet; NVS persistence is a later implementation gate. +are rejected, never clamped. Atomic mode/target mutations now model these +semantics; NVS persistence remains a later implementation gate. + +## Control authority and control inhibits + +Operating mode, active control authority, recoverable control inhibit, latched +hardware/safety fault, and thermal handling are separate state dimensions. +Authority is one of NONE, REMOTE, AUTOMATIC, or REACQUIRING. + +MANUAL heater demand is bound to a 60-second remote lease. The timeout is an +initial product/network policy, not a hardware safety threshold. Lease expiry +or explicit takeover immediately removes MANUAL demand, advances the generation, +and creates a recoverable `no_authority` inhibit. It does not create or clear a +hardware fault. Autonomous cooldown and fault fan policy continues. A reconnect +retains visible configuration but never restores old demand: the client must +acquire authority, complete an authoritative refresh, and submit a new eligible +request. + +No client silently wins a remote lease. A different owner receives +`control_authority_required` unless it explicitly confirms takeover. Takeover +revokes the old lease, zeros prior remote demand, advances generation, and also +requires refresh plus a new request. + +AUTOMATIC authority is product-local after an atomic transition commits. Its +demand never depends on a browser heartbeat or inherits REMOTE authority. It +continues after browser lease expiry only while fresh exact `PRINTING` status, +valid sensors, fan proof, interlocks, policy eligibility, and all safety vetoes +remain satisfied. The bed-target policy remains undefined, so production +AUTOMATIC heating stays unavailable. + +Mutations carry lease ID, control generation, expected state revision, complete +requested state, and an optional idempotency UUID. Lease, generation, revision, +current inputs, faults, and transition eligibility are validated atomically; +the server commits the complete request or nothing. Stable failures are +`control_authority_required`, `control_authority_lost`, +`control_generation_stale`, `state_revision_conflict`, +`control_request_ineligible`, `control_request_conflict`, and +`hardware_fault_latched`. Reacquisition never acknowledges a real fault. ## AUTOMATIC and PrusaLink diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 5d4c183..d1217bf 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,6 +1,6 @@ idf_component_register( SRCS "app_main.c" INCLUDE_DIRS "." - REQUIRES jj_identity jj_interlock jj_portal dc_evlog dc_prusa dc_wifi nvs_flash app_update + REQUIRES jj_authority jj_identity jj_interlock jj_portal dc_evlog dc_prusa dc_wifi nvs_flash app_update ) target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/main/app_main.c b/main/app_main.c index 1365cd1..55cf0f5 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -5,12 +5,14 @@ #include "dc_evlog.h" #include "dc_prusa.h" #include "dc_wifi.h" +#include "jj_authority.h" #include "jj_identity.h" #include "jj_interlock.h" #include "jj_portal.h" #include "esp_check.h" #include "esp_log.h" #include "esp_ota_ops.h" +#include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "nvs_flash.h" @@ -18,6 +20,7 @@ static const char *TAG = JJ_IDENTITY_PRODUCT_ID; static jj_interlock_t s_interlock; +static jj_authority_t s_authority; static bool printer_is_printing(const char *state) { @@ -31,6 +34,11 @@ static void control_task(void *arg) jj_block_reason_t previous = JJ_BLOCK_NONE; for (;;) { jj_inputs_t input = jj_inputs_safe_defaults(); + const uint64_t now_ms = (uint64_t)esp_timer_get_time() / 1000U; + (void)jj_authority_tick(&s_authority, now_ms); + jj_control_snapshot_t authority; + jj_authority_snapshot(&s_authority, now_ms, &authority); + jj_authority_apply_to_inputs(&authority, &input); dc_prusa_status_t printer = {0}; if (dc_prusa_get_status(&printer) == ESP_OK) { input.printer.online = printer.online; @@ -65,6 +73,7 @@ void app_main(void) } ESP_ERROR_CHECK(err); jj_interlock_init(&s_interlock); + jj_authority_init(&s_authority, JJ_REMOTE_LEASE_TTL_MS); BaseType_t created = xTaskCreate(control_task, "jj_control", 4096, xTaskGetCurrentTaskHandle(), 8, NULL); ESP_ERROR_CHECK(created == pdPASS ? ESP_OK : ESP_ERR_NO_MEM); @@ -77,7 +86,7 @@ void app_main(void) ESP_ERROR_CHECK(dc_wifi_set_identity(&identity)); ESP_ERROR_CHECK(dc_wifi_start()); ESP_ERROR_CHECK(dc_prusa_start()); - ESP_ERROR_CHECK(jj_portal_start(&s_interlock)); + ESP_ERROR_CHECK(jj_portal_start(&s_interlock, &s_authority)); 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); diff --git a/tests/check_api_contract.sh b/tests/check_api_contract.sh index 3fc7622..73a06c6 100755 --- a/tests/check_api_contract.sh +++ b/tests/check_api_contract.sh @@ -29,6 +29,19 @@ grep -q 'JJ_MANUAL_TARGET_DEFAULT_C 45.0f' "$header" grep -q 'JJ_MANUAL_TARGET_MAX_C 50.0f' "$header" grep -q 'strcmp(state, "PRINTING") == 0' "$app" +for route in '/api/v2/control/acquire' '/api/v2/control/refresh' \ + '/api/v2/control/heartbeat' '/api/v2/control/mutate'; do + grep -q "$route" "$portal" +done +for error in 'control_authority_required' 'control_authority_lost' \ + 'control_generation_stale' 'state_revision_conflict' \ + 'control_request_ineligible' 'hardware_fault_latched'; do + grep -q "$error" "$root/components/jj_authority/jj_authority.c" +done +grep -q 'application/json' "$portal" +grep -q 'authority->active_authority == JJ_AUTHORITY_REMOTE' "$portal" +grep -q 'authority->active_authority == JJ_AUTHORITY_AUTOMATIC' "$portal" + if rg -n 'power_on|heater_control|fan_control|"heating"|"fan"' "$portal" \ | grep 'cJSON_CreateString' >/dev/null; then echo "unavailable physical capability is advertised" >&2 diff --git a/tests/check_ota_contract.sh b/tests/check_ota_contract.sh index 00a3966..d350cd4 100644 --- a/tests/check_ota_contract.sh +++ b/tests/check_ota_contract.sh @@ -21,7 +21,7 @@ grep -q 'jj_interlock_snapshot(&s_interlock)' "$app" grep -q 'portENTER_CRITICAL(&s_state_lock)' "$interlock" grep -q 'portEXIT_CRITICAL(&s_state_lock)' "$interlock" -portal_line=$(grep -n 'jj_portal_start(&s_interlock)' "$app" | cut -d: -f1) +portal_line=$(grep -n 'jj_portal_start(&s_interlock, &s_authority)' "$app" | cut -d: -f1) health_line=$(grep -n 'ulTaskNotifyTake(pdTRUE' "$app" | cut -d: -f1) valid_line=$(grep -n 'esp_ota_mark_app_valid_cancel_rollback' "$app" | cut -d: -f1) [ "$portal_line" -lt "$health_line" ] diff --git a/tests/control-client.test.cjs b/tests/control-client.test.cjs new file mode 100644 index 0000000..0fa6a2e --- /dev/null +++ b/tests/control-client.test.cjs @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { + Client, + ControlError, + controllerLabel, + constraintMessage, +} = require("../components/jj_portal/web/control-client.js"); + +function state(generation, revision, leaseId) { + return { + boot_id: "boot-a", + state_revision: revision, + mode: "off", + controller: "none", + control: { + generation, + lease_id: leaseId, + authority: "none", + inhibit: "none", + }, + }; +} + +function response(payload, ok = true) { + return {ok, json: async () => payload}; +} + +function scriptedFetch(script, calls) { + return async (path, options) => { + const body = JSON.parse(options.body); + calls.push({path, body}); + const next = script.shift(); + assert.ok(next, "unexpected fetch"); + return typeof next === "function" ? next(path, body) : next; + }; +} + +test("resume acquires authority then performs authoritative refresh", async () => { + const calls = []; + const ready = []; + const fetch = scriptedFetch([ + response(state(2, 2, "a".repeat(32))), + response(state(2, 3, "a".repeat(32))), + ], calls); + const client = new Client({fetch, owner: "phone", onReady: value => ready.push(value)}); + await client.resume(false); + assert.deepEqual(calls.map(call => call.path), [ + "/api/v2/control/acquire", + "/api/v2/control/refresh", + ]); + assert.equal(calls[0].body.takeover, false); + assert.equal(calls[1].body.control_generation, 2); + assert.equal(client.ready, true); + assert.deepEqual(ready, [false, true]); +}); + +test("takeover is sent only after explicit caller confirmation", async () => { + const calls = []; + const fetch = scriptedFetch([ + response(state(2, 2, "b".repeat(32))), + response(state(2, 3, "b".repeat(32))), + ], calls); + const client = new Client({fetch, owner: "phone"}); + await client.resume(true); + assert.equal(calls[0].body.takeover, true); +}); + +test("mutation carries causal tokens and requested final semantic state", async () => { + const calls = []; + const fetch = scriptedFetch([ + response(state(2, 2, "a".repeat(32))), + response(state(2, 3, "a".repeat(32))), + response({...state(2, 4, "a".repeat(32)), mode: "manual"}), + ], calls); + const client = new Client({fetch, owner: "phone", uuid: () => + "12345678-1234-1234-1234-123456789abc"}); + await client.resume(false); + await client.mutate("manual", 46); + assert.deepEqual(calls[2].body, { + lease_id: "a".repeat(32), + control_generation: 2, + expected_state_revision: 3, + request_id: "12345678-1234-1234-1234-123456789abc", + action: "manual", + target_c: 46, + }); +}); + +test("control loss disables mutation until reacquisition and refresh", async () => { + const calls = []; + const fetch = scriptedFetch([ + response(state(2, 2, "a".repeat(32))), + response(state(2, 3, "a".repeat(32))), + response({error: "control_authority_lost", state: state(3, 4)}, false), + ], calls); + const client = new Client({fetch, owner: "phone"}); + await client.resume(false); + await assert.rejects(client.mutate("off"), error => + error instanceof ControlError && error.code === "control_authority_lost"); + assert.equal(client.ready, false); + await assert.rejects(client.mutate("off"), error => + error.code === "control_authority_required"); + assert.equal(calls.length, 3); +}); + +test("late mutation response from an older generation cannot replace current state", async () => { + let resolveMutation; + const mutationResponse = new Promise(resolve => { resolveMutation = resolve; }); + const calls = []; + const fetch = scriptedFetch([ + response(state(2, 2, "a".repeat(32))), + response(state(2, 3, "a".repeat(32))), + () => mutationResponse, + response(state(3, 5, "b".repeat(32))), + response(state(3, 6, "b".repeat(32))), + ], calls); + const client = new Client({fetch, owner: "phone"}); + await client.resume(false); + const late = client.mutate("off"); + await client.resume(false); + resolveMutation(response({...state(2, 4, "a".repeat(32)), mode: "manual"})); + await late; + assert.equal(client.generation, 3); + assert.equal(client.revision, 6); + assert.equal(client.state.mode, "off"); +}); + +test("page foreground and restored-page events reacquire and refresh", () => { + const page = fs.readFileSync(path.join( + __dirname, "../components/jj_portal/web/control.html"), "utf8"); + assert.match(page, /visibilitychange/); + assert.match(page, /visibilityState === "visible"\) resume\(false\)/); + assert.match(page, /pageshow/); + assert.match(page, /event\.persisted\) resume\(false\)/); +}); + +test("primary controller label follows effective authority, not configured mode", () => { + const retainedManual = state(3, 5); + retainedManual.mode = "manual"; + retainedManual.controller = "remote"; + retainedManual.control.authority = "none"; + assert.equal(controllerLabel(retainedManual), "Manual · None"); + retainedManual.control.authority = "reacquiring"; + assert.equal(controllerLabel(retainedManual), "Manual · Reacquiring"); + retainedManual.mode = "automatic"; + retainedManual.control.authority = "automatic"; + assert.equal(controllerLabel(retainedManual), "Automatic · PrusaLink"); +}); + +test("primary constraints are operator-readable while unknown codes stay generic", () => { + assert.equal(constraintMessage("control_no_authority"), + "Control inhibited: remote controller disconnected"); + assert.equal(constraintMessage("printer_unavailable"), + "Heating inhibited: PrusaLink status unavailable or stale"); + assert.equal(constraintMessage("not_commissioned"), + "Heating unavailable: product not commissioned"); + assert.equal(constraintMessage("future_internal_code"), + "Heating inhibited: see advanced diagnostics"); +}); diff --git a/tests/jj_authority_host_test.c b/tests/jj_authority_host_test.c new file mode 100644 index 0000000..6cae7ae --- /dev/null +++ b/tests/jj_authority_host_test.c @@ -0,0 +1,423 @@ +#include "jj_authority.h" +#include +#include +#include + +#define CHECK(condition) do { if (!(condition)) { \ + fprintf(stderr, "%s:%d: CHECK failed: %s\n", __FILE__, __LINE__, #condition); \ + exit(1); } } while (0) + +#define LEASE_A "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +#define LEASE_B "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +static jj_inputs_t nominal_inputs(void) +{ + return (jj_inputs_t){ + .commissioned = true, + .mode = JJ_MODE_OFF, + .manual_target_c = JJ_MANUAL_TARGET_DEFAULT_C, + .chamber = {.status = JJ_SENSOR_OK, .temperature_c = 30.0f}, + .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, + }; +} + +static jj_control_snapshot_t acquire( + jj_authority_t *authority, + const char *lease, + const char *owner, + bool takeover, + uint64_t now_ms) +{ + jj_control_acquire_t request = {.takeover = takeover}; + snprintf(request.lease_id, sizeof request.lease_id, "%s", lease); + snprintf(request.owner, sizeof request.owner, "%s", owner); + jj_control_snapshot_t snapshot; + CHECK(jj_authority_acquire(authority, &request, now_ms, &snapshot) == + JJ_CONTROL_OK); + return snapshot; +} + +static jj_control_snapshot_t mutate( + jj_authority_t *authority, + jj_interlock_t *interlock, + const jj_control_snapshot_t *lease, + jj_control_mutation_kind_t kind, + float target_c, + const char *request_id, + const jj_inputs_t *inputs, + uint64_t now_ms) +{ + jj_control_mutation_t request = { + .generation = lease->generation, + .expected_revision = lease->revision, + .kind = kind, + .target_c = target_c, + }; + snprintf(request.lease_id, sizeof request.lease_id, "%s", lease->lease_id); + snprintf(request.request_id, sizeof request.request_id, "%s", + request_id ? request_id : ""); + jj_control_snapshot_t snapshot; + CHECK(jj_authority_mutate(authority, interlock, &request, inputs, now_ms, + &snapshot) == JJ_CONTROL_OK); + return snapshot; +} + +static jj_control_snapshot_t refresh( + jj_authority_t *authority, + const jj_control_snapshot_t *lease, + uint64_t now_ms) +{ + jj_control_snapshot_t snapshot; + CHECK(jj_authority_refresh(authority, lease->lease_id, lease->generation, + now_ms, &snapshot) == JJ_CONTROL_OK); + return snapshot; +} + +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); +} + +static void test_manual_expiry_fails_cold_without_fault_and_keeps_cooldown(void) +{ + jj_authority_t authority; + jj_interlock_t interlock; + jj_authority_init(&authority, 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); + 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(jj_authority_tick(&authority, 1000)); + jj_control_snapshot_t expired; + jj_authority_snapshot(&authority, 1000, &expired); + CHECK(expired.mode == JJ_MODE_MANUAL); + CHECK(expired.active_authority == JJ_AUTHORITY_NONE); + CHECK(expired.control_inhibit == JJ_CONTROL_INHIBIT_NO_AUTHORITY); + CHECK(!expired.manual_demand_authorized); + inputs.cooldown_required = true; + jj_outputs_t output = evaluate(&interlock, &expired, inputs); + CHECK(!output.heater_requested); + CHECK(output.fault == JJ_FAULT_NONE); + CHECK(output.control_inhibit == JJ_CONTROL_INHIBIT_NO_AUTHORITY); + CHECK(output.thermal_state == JJ_THERMAL_COOLDOWN); + CHECK(output.fan_percent == 100); +} + +static void test_reacquire_requires_refresh_and_new_explicit_request(void) +{ + jj_authority_t authority; + jj_interlock_t interlock; + jj_authority_init(&authority, 1000); + jj_interlock_init(&interlock); + jj_inputs_t inputs = nominal_inputs(); + jj_control_snapshot_t first = acquire(&authority, LEASE_A, "phone", false, 0); + first = refresh(&authority, &first, 0); + first = mutate(&authority, &interlock, &first, JJ_MUTATION_MANUAL, 44.0f, + "manual-a", &inputs, 1); + CHECK(jj_authority_tick(&authority, 1001)); + + jj_control_snapshot_t reacquired = acquire( + &authority, LEASE_B, "phone", false, 1002); + CHECK(reacquired.active_authority == JJ_AUTHORITY_REACQUIRING); + CHECK(reacquired.control_inhibit == + JJ_CONTROL_INHIBIT_STATE_REFRESH_REQUIRED); + CHECK(!reacquired.manual_demand_authorized); + CHECK(!evaluate(&interlock, &reacquired, inputs).heater_requested); + + jj_control_mutation_t premature = { + .generation = reacquired.generation, + .expected_revision = reacquired.revision, + .kind = JJ_MUTATION_MANUAL, + .target_c = 44.0f, + }; + snprintf(premature.lease_id, sizeof premature.lease_id, "%s", LEASE_B); + jj_control_snapshot_t rejected; + CHECK(jj_authority_mutate(&authority, &interlock, &premature, &inputs, + 1003, &rejected) == + JJ_CONTROL_AUTHORITY_REQUIRED); + CHECK(!rejected.manual_demand_authorized); + + reacquired = refresh(&authority, &reacquired, 1004); + CHECK(reacquired.active_authority == JJ_AUTHORITY_NONE); + CHECK(reacquired.control_inhibit == JJ_CONTROL_INHIBIT_NO_AUTHORITY); + + jj_control_snapshot_t resumed = mutate( + &authority, &interlock, &reacquired, JJ_MUTATION_MANUAL, 44.0f, + "manual-b", &inputs, 1005); + CHECK(resumed.active_authority == JJ_AUTHORITY_REMOTE); + CHECK(resumed.manual_demand_authorized); + CHECK(evaluate(&interlock, &resumed, inputs).heater_requested); +} + +static void test_stale_generation_and_revision_are_atomic(void) +{ + jj_authority_t authority; + jj_interlock_t interlock; + jj_authority_init(&authority, 1000); + jj_interlock_init(&interlock); + jj_inputs_t inputs = nominal_inputs(); + jj_control_snapshot_t lease = acquire(&authority, LEASE_A, "phone", false, 0); + lease = refresh(&authority, &lease, 0); + jj_control_mutation_t request = { + .generation = lease.generation - 1, + .expected_revision = lease.revision, + .kind = JJ_MUTATION_MANUAL, + .target_c = 45.0f, + }; + snprintf(request.lease_id, sizeof request.lease_id, "%s", LEASE_A); + jj_control_snapshot_t rejected; + CHECK(jj_authority_mutate(&authority, &interlock, &request, &inputs, 1, + &rejected) == JJ_CONTROL_GENERATION_STALE); + CHECK(rejected.mode == JJ_MODE_OFF); + CHECK(!rejected.manual_demand_authorized); + + request.generation = lease.generation; + request.expected_revision = lease.revision - 1; + CHECK(jj_authority_mutate(&authority, &interlock, &request, &inputs, 2, + &rejected) == JJ_CONTROL_REVISION_CONFLICT); + CHECK(rejected.mode == JJ_MODE_OFF); + CHECK(!rejected.manual_demand_authorized); +} + +static void test_explicit_takeover_revokes_old_remote_demand(void) +{ + jj_authority_t authority; + jj_interlock_t interlock; + jj_authority_init(&authority, 1000); + jj_interlock_init(&interlock); + jj_inputs_t inputs = nominal_inputs(); + jj_control_snapshot_t first = acquire(&authority, LEASE_A, "phone-a", false, 0); + first = refresh(&authority, &first, 0); + first = mutate(&authority, &interlock, &first, JJ_MUTATION_MANUAL, 45.0f, + "manual-a", &inputs, 1); + + jj_control_acquire_t denied = {.takeover = false}; + snprintf(denied.lease_id, sizeof denied.lease_id, "%s", LEASE_B); + snprintf(denied.owner, sizeof denied.owner, "%s", "phone-b"); + jj_control_snapshot_t unchanged; + CHECK(jj_authority_acquire(&authority, &denied, 2, &unchanged) == + JJ_CONTROL_AUTHORITY_REQUIRED); + CHECK(unchanged.manual_demand_authorized); + + jj_control_snapshot_t takeover = acquire( + &authority, LEASE_B, "phone-b", true, 3); + CHECK(takeover.generation > first.generation); + CHECK(!takeover.manual_demand_authorized); + CHECK(takeover.active_authority == JJ_AUTHORITY_REACQUIRING); + CHECK(!evaluate(&interlock, &takeover, inputs).heater_requested); + jj_control_snapshot_t heartbeat; + CHECK(jj_authority_heartbeat(&authority, LEASE_A, first.generation, 4, + &heartbeat) == JJ_CONTROL_GENERATION_STALE); +} + +static void test_automatic_survives_browser_loss_but_eligibility_fails_cold(void) +{ + jj_authority_t authority; + jj_interlock_t interlock; + jj_authority_init(&authority, 1000); + jj_interlock_init(&interlock); + jj_inputs_t inputs = nominal_inputs(); + inputs.automatic_target_available = true; + inputs.automatic_target_c = 42.0f; + jj_control_snapshot_t lease = acquire(&authority, LEASE_A, "phone", false, 0); + lease = refresh(&authority, &lease, 0); + jj_control_snapshot_t automatic = mutate( + &authority, &interlock, &lease, JJ_MUTATION_AUTOMATIC, 0.0f, + "auto-a", &inputs, 1); + CHECK(evaluate(&interlock, &automatic, inputs).heater_requested); + + CHECK(jj_authority_tick(&authority, 1000)); + jj_control_snapshot_t expired; + jj_authority_snapshot(&authority, 1000, &expired); + CHECK(expired.mode == JJ_MODE_AUTOMATIC); + CHECK(expired.active_authority == JJ_AUTHORITY_AUTOMATIC); + CHECK(evaluate(&interlock, &expired, inputs).heater_requested); + + inputs.printer.online = false; + jj_outputs_t output = evaluate(&interlock, &expired, inputs); + CHECK(!output.heater_requested); + 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.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.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.block_reason == JJ_BLOCK_FAN_PROOF_PENDING); +} + +static void test_automatic_transition_requires_current_product_eligibility(void) +{ + jj_authority_t authority; + jj_interlock_t interlock; + jj_authority_init(&authority, 1000); + jj_interlock_init(&interlock); + jj_inputs_t inputs = nominal_inputs(); + jj_control_snapshot_t lease = acquire(&authority, LEASE_A, "phone", false, 0); + lease = refresh(&authority, &lease, 0); + jj_control_mutation_t request = { + .generation = lease.generation, + .expected_revision = lease.revision, + .kind = JJ_MUTATION_AUTOMATIC, + }; + snprintf(request.lease_id, sizeof request.lease_id, "%s", LEASE_A); + jj_control_snapshot_t rejected; + CHECK(jj_authority_mutate(&authority, &interlock, &request, &inputs, 1, + &rejected) == JJ_CONTROL_REQUEST_INELIGIBLE); + CHECK(rejected.mode == JJ_MODE_OFF); + inputs.automatic_target_available = true; + inputs.automatic_target_c = 42.0f; + inputs.printer.printing = false; + request.expected_revision = rejected.revision; + CHECK(jj_authority_mutate(&authority, &interlock, &request, &inputs, 2, + &rejected) == JJ_CONTROL_REQUEST_INELIGIBLE); + CHECK(rejected.mode == JJ_MODE_OFF); +} + +static void test_faults_are_not_cleared_by_authority_and_client_cannot_bypass(void) +{ + jj_authority_t authority; + jj_interlock_t interlock; + jj_authority_init(&authority, 1000); + jj_interlock_init(&interlock); + 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); + + jj_control_snapshot_t lease = acquire(&authority, LEASE_A, "phone", false, 0); + lease = refresh(&authority, &lease, 0); + jj_control_mutation_t request = { + .generation = lease.generation, + .expected_revision = lease.revision, + .kind = JJ_MUTATION_MANUAL, + .target_c = 45.0f, + }; + snprintf(request.lease_id, sizeof request.lease_id, "%s", LEASE_A); + jj_control_snapshot_t rejected; + CHECK(jj_authority_mutate(&authority, &interlock, &request, &inputs, 1, + &rejected) == JJ_CONTROL_HARDWARE_FAULT); + CHECK(jj_interlock_snapshot(&interlock).fault == JJ_FAULT_SENSOR); + + jj_interlock_init(&interlock); + inputs.chamber.status = JJ_SENSOR_OPEN; + lease = acquire(&authority, LEASE_B, "phone", false, 2); + lease = refresh(&authority, &lease, 2); + request.generation = lease.generation; + request.expected_revision = lease.revision; + snprintf(request.lease_id, sizeof request.lease_id, "%s", LEASE_B); + CHECK(jj_authority_mutate(&authority, &interlock, &request, &inputs, 3, + &rejected) == JJ_CONTROL_REQUEST_INELIGIBLE); + CHECK(rejected.mode == JJ_MODE_OFF); + CHECK(!rejected.manual_demand_authorized); +} + +static void test_request_uuid_retry_is_exactly_once_and_content_bound(void) +{ + jj_authority_t authority; + jj_interlock_t interlock; + jj_authority_init(&authority, 1000); + jj_interlock_init(&interlock); + jj_inputs_t inputs = nominal_inputs(); + jj_control_snapshot_t lease = acquire(&authority, LEASE_A, "phone", false, 0); + lease = refresh(&authority, &lease, 0); + jj_control_mutation_t request = { + .generation = lease.generation, + .expected_revision = lease.revision, + .kind = JJ_MUTATION_MANUAL, + .target_c = 45.0f, + }; + snprintf(request.lease_id, sizeof request.lease_id, "%s", LEASE_A); + snprintf(request.request_id, sizeof request.request_id, "%s", "same-request"); + jj_control_snapshot_t first; + CHECK(jj_authority_mutate(&authority, &interlock, &request, &inputs, 1, + &first) == JJ_CONTROL_OK); + jj_control_snapshot_t retry; + CHECK(jj_authority_mutate(&authority, &interlock, &request, &inputs, 2, + &retry) == JJ_CONTROL_OK); + CHECK(retry.revision == first.revision); + jj_control_mutation_t intervening = request; + snprintf(intervening.request_id, sizeof intervening.request_id, "%s", + "different-request"); + intervening.expected_revision = first.revision; + intervening.kind = JJ_MUTATION_OFF; + jj_control_snapshot_t off; + CHECK(jj_authority_mutate(&authority, &interlock, &intervening, &inputs, 3, + &off) == JJ_CONTROL_OK); + request.target_c = 45.0f; + CHECK(jj_authority_mutate(&authority, &interlock, &request, &inputs, 4, + &retry) == JJ_CONTROL_OK); + CHECK(retry.revision == off.revision); + CHECK(retry.mode == JJ_MODE_OFF); + request.target_c = 46.0f; + CHECK(jj_authority_mutate(&authority, &interlock, &request, &inputs, 5, + &retry) == JJ_CONTROL_REQUEST_CONFLICT); + CHECK(off.mode == JJ_MODE_OFF); +} + +static void test_retry_after_expiry_never_replays_historical_manual_demand(void) +{ + jj_authority_t authority; + jj_interlock_t interlock; + jj_authority_init(&authority, 1000); + jj_interlock_init(&interlock); + jj_inputs_t inputs = nominal_inputs(); + jj_control_snapshot_t lease = acquire(&authority, LEASE_A, "phone", false, 0); + lease = refresh(&authority, &lease, 0); + jj_control_mutation_t request = { + .generation = lease.generation, + .expected_revision = lease.revision, + .kind = JJ_MUTATION_MANUAL, + .target_c = 45.0f, + }; + snprintf(request.lease_id, sizeof request.lease_id, "%s", LEASE_A); + snprintf(request.request_id, sizeof request.request_id, "%s", "lost-response"); + jj_control_snapshot_t accepted; + CHECK(jj_authority_mutate(&authority, &interlock, &request, &inputs, 1, + &accepted) == JJ_CONTROL_OK); + CHECK(accepted.manual_demand_authorized); + jj_control_snapshot_t retry; + CHECK(jj_authority_mutate(&authority, &interlock, &request, &inputs, 1001, + &retry) == JJ_CONTROL_OK); + CHECK(!retry.lease_active); + CHECK(!retry.manual_demand_authorized); + CHECK(retry.active_authority == JJ_AUTHORITY_NONE); + CHECK(!evaluate(&interlock, &retry, inputs).heater_requested); +} + +int main(void) +{ + 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(); + test_explicit_takeover_revokes_old_remote_demand(); + test_automatic_survives_browser_loss_but_eligibility_fails_cold(); + test_automatic_transition_requires_current_product_eligibility(); + test_faults_are_not_cleared_by_authority_and_client_cannot_bypass(); + test_request_uuid_retry_is_exactly_once_and_content_bound(); + test_retry_after_expiry_never_replays_historical_manual_demand(); + puts("jj_authority_host_test: PASS"); + return 0; +} diff --git a/tests/jj_interlock_host_test.c b/tests/jj_interlock_host_test.c index 36dd8d3..e2618ca 100644 --- a/tests/jj_interlock_host_test.c +++ b/tests/jj_interlock_host_test.c @@ -9,7 +9,7 @@ static jj_inputs_t nominal(jj_mode_t mode) { - return (jj_inputs_t){ + jj_inputs_t input = { .commissioned = true, .mode = mode, .manual_target_c = JJ_MANUAL_TARGET_DEFAULT_C, @@ -19,6 +19,13 @@ static jj_inputs_t nominal(jj_mode_t mode) .printer = {.online = true, .printing = true, .bed_target_c = 100.0f}, .fan_proof = JJ_FAN_PROOF_PROVEN, }; + if (mode == JJ_MODE_MANUAL) { + input.active_authority = JJ_AUTHORITY_REMOTE; + input.manual_demand_authorized = true; + } else if (mode == JJ_MODE_AUTOMATIC) { + input.active_authority = JJ_AUTHORITY_AUTOMATIC; + } + return input; } static jj_outputs_t step_once(jj_inputs_t input) @@ -87,6 +94,26 @@ static void test_automatic_is_whitelisted_and_policy_blocked(void) check_cold(step_once(input), JJ_BLOCK_AUTO_POLICY_UNAVAILABLE); } +static void test_automatic_authority_and_eligibility_are_independent(void) +{ + jj_inputs_t input = nominal(JJ_MODE_AUTOMATIC); + input.automatic_target_available = true; + input.automatic_target_c = 42.0f; + jj_outputs_t output = step_once(input); + CHECK(output.heater_requested); + CHECK(output.active_authority == JJ_AUTHORITY_AUTOMATIC); + CHECK(output.thermal_state == JJ_THERMAL_HEATING); + + input.active_authority = JJ_AUTHORITY_NONE; + check_cold(step_once(input), JJ_BLOCK_AUTO_AUTHORITY_UNAVAILABLE); + input.active_authority = JJ_AUTHORITY_AUTOMATIC; + input.printer.online = false; + output = step_once(input); + check_cold(output, JJ_BLOCK_PRINTER_UNAVAILABLE); + CHECK(output.control_inhibit == JJ_CONTROL_INHIBIT_NOT_ELIGIBLE); + CHECK(output.fault == JJ_FAULT_NONE); +} + static void test_faults_latch_and_need_explicit_safe_clear(void) { jj_interlock_t state; @@ -151,6 +178,7 @@ int main(void) test_boot_defaults_and_modes(); test_manual_target_is_rejected_not_clamped(); test_automatic_is_whitelisted_and_policy_blocked(); + test_automatic_authority_and_eligibility_are_independent(); test_faults_latch_and_need_explicit_safe_clear(); test_off_keeps_thermal_management_request(); test_fan_proof_and_null_inputs_fail_cold(); diff --git a/tests/run_authority_host_test.sh b/tests/run_authority_host_test.sh new file mode 100755 index 0000000..f4b8d56 --- /dev/null +++ b/tests/run_authority_host_test.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +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" \ + "$root/components/jj_interlock/jj_interlock.c" \ + "$root/components/jj_authority/jj_authority.c" \ + "$root/tests/jj_authority_host_test.c" -lm -o "$out" +"$out" From 7c5b96269e68c2e42b799637a13074bf5cd4bf47 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:50:29 -0700 Subject: [PATCH 2/5] fix(portal): use generated asset symbols --- components/jj_portal/jj_portal.c | 20 ++++++++++---------- tests/check_api_contract.sh | 2 ++ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/components/jj_portal/jj_portal.c b/components/jj_portal/jj_portal.c index b36e2ac..a504cb2 100644 --- a/components/jj_portal/jj_portal.c +++ b/components/jj_portal/jj_portal.c @@ -24,10 +24,10 @@ static jj_authority_t *s_authority; static char s_device_id[32]; static char s_boot_id[33]; -extern const unsigned char _binary_web_control_html_start[]; -extern const unsigned char _binary_web_control_html_end[]; -extern const unsigned char _binary_web_control_client_js_start[]; -extern const unsigned char _binary_web_control_client_js_end[]; +extern const unsigned char _binary_control_html_start[]; +extern const unsigned char _binary_control_html_end[]; +extern const unsigned char _binary_control_client_js_start[]; +extern const unsigned char _binary_control_client_js_end[]; static uint64_t monotonic_ms(void) { @@ -299,9 +299,9 @@ static esp_err_t control_page_get(httpd_req_t *req) { httpd_resp_set_type(req, "text/html; charset=utf-8"); httpd_resp_set_hdr(req, "Cache-Control", "no-store"); - return httpd_resp_send(req, (const char *)_binary_web_control_html_start, - (uintptr_t)_binary_web_control_html_end - - (uintptr_t)_binary_web_control_html_start); + return httpd_resp_send(req, (const char *)_binary_control_html_start, + (uintptr_t)_binary_control_html_end - + (uintptr_t)_binary_control_html_start); } static esp_err_t control_script_get(httpd_req_t *req) @@ -309,9 +309,9 @@ static esp_err_t control_script_get(httpd_req_t *req) httpd_resp_set_type(req, "application/javascript; charset=utf-8"); httpd_resp_set_hdr(req, "Cache-Control", "no-store"); return httpd_resp_send(req, - (const char *)_binary_web_control_client_js_start, - (uintptr_t)_binary_web_control_client_js_end - - (uintptr_t)_binary_web_control_client_js_start); + (const char *)_binary_control_client_js_start, + (uintptr_t)_binary_control_client_js_end - + (uintptr_t)_binary_control_client_js_start); } static const char *result_status(jj_control_result_t result) diff --git a/tests/check_api_contract.sh b/tests/check_api_contract.sh index 73a06c6..95a5eaa 100755 --- a/tests/check_api_contract.sh +++ b/tests/check_api_contract.sh @@ -41,6 +41,8 @@ done grep -q 'application/json' "$portal" grep -q 'authority->active_authority == JJ_AUTHORITY_REMOTE' "$portal" grep -q 'authority->active_authority == JJ_AUTHORITY_AUTOMATIC' "$portal" +grep -q '_binary_control_html_start' "$portal" +grep -q '_binary_control_client_js_start' "$portal" if rg -n 'power_on|heater_control|fan_control|"heating"|"fan"' "$portal" \ | grep 'cJSON_CreateString' >/dev/null; then From eb7b5c37e5434a0101c46c4ede054beec211f444 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:50:31 -0700 Subject: [PATCH 3/5] fix(control): enforce directional authority safety --- .github/workflows/firmware-build.yml | 3 + README.md | 1 + .../jj_authority/include/jj_authority.h | 21 ++- components/jj_authority/jj_authority.c | 100 +++++++++-- .../jj_interlock/include/jj_interlock.h | 18 +- components/jj_interlock/jj_interlock.c | 77 ++++++++ components/jj_portal/jj_portal.c | 19 +- components/jj_portal/web/control-client.js | 7 + components/jj_portal/web/control.html | 2 +- docs/ARCHITECTURE.md | 15 ++ docs/CONTROL_AUTHORITY.md | 28 +++ docs/PRODUCT_SAFETY_CONTRACT.md | 18 ++ main/app_main.c | 15 +- tests/check_control_safety_contract.sh | 34 ++++ tests/control-client.test.cjs | 20 +++ tests/jj_authority_host_test.c | 170 ++++++++++++++++-- tests/jj_interlock_host_test.c | 75 ++++++++ 17 files changed, 571 insertions(+), 52 deletions(-) create mode 100755 tests/check_control_safety_contract.sh diff --git a/.github/workflows/firmware-build.yml b/.github/workflows/firmware-build.yml index 5350431..ade2e5a 100644 --- a/.github/workflows/firmware-build.yml +++ b/.github/workflows/firmware-build.yml @@ -44,6 +44,9 @@ jobs: - name: Check API and product contract run: sh tests/check_api_contract.sh + - name: Check control ordering, locking, and fault policy + run: sh tests/check_control_safety_contract.sh + - name: Enforce production actuation allowlist run: sh tests/check_actuation_allowlist.sh diff --git a/README.md b/README.md index cff2dd7..8a6d912 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ fault-injection evidence. The browser and API are never the safety boundary. ./tests/run_interlock_host_test.sh ./tests/run_authority_host_test.sh node --test tests/control-client.test.cjs +sh tests/check_control_safety_contract.sh sh tests/check_identity_contract.sh sh tests/check_api_contract.sh sh tests/check_actuation_allowlist.sh diff --git a/components/jj_authority/include/jj_authority.h b/components/jj_authority/include/jj_authority.h index 74c1376..36239d8 100644 --- a/components/jj_authority/include/jj_authority.h +++ b/components/jj_authority/include/jj_authority.h @@ -30,6 +30,13 @@ typedef enum { JJ_MUTATION_CLEAR_FAULT, } jj_control_mutation_kind_t; +typedef enum { + JJ_CONTROL_LOSS_NONE = 0, + JJ_CONTROL_LOSS_REMOTE_LEASE_EXPIRED, + JJ_CONTROL_LOSS_EXPLICIT_TAKEOVER, + JJ_CONTROL_LOSS_REMOTE_REACQUIRED, +} jj_control_loss_reason_t; + typedef struct { char lease_id[JJ_CONTROL_LEASE_ID_LEN + 1]; char owner[JJ_CONTROL_OWNER_LEN + 1]; @@ -58,7 +65,7 @@ typedef struct { char lease_owner[JJ_CONTROL_OWNER_LEN + 1]; uint32_t lease_expires_in_ms; uint64_t last_control_loss_ms; - char last_control_loss_reason[32]; + jj_control_loss_reason_t last_control_loss_reason; } jj_control_snapshot_t; typedef struct { @@ -71,11 +78,15 @@ typedef struct { jj_control_snapshot_t state; uint64_t lease_deadline_ms; uint32_t lease_ttl_ms; + jj_interlock_t *interlock; jj_control_request_cache_entry_t request_cache[JJ_CONTROL_REQUEST_CACHE_SIZE]; uint8_t next_request_cache_entry; } jj_authority_t; -void jj_authority_init(jj_authority_t *state, uint32_t lease_ttl_ms); +void jj_authority_init( + jj_authority_t *state, + jj_interlock_t *interlock, + uint32_t lease_ttl_ms); jj_control_result_t jj_authority_acquire( jj_authority_t *state, const jj_control_acquire_t *request, @@ -108,5 +119,11 @@ void jj_authority_snapshot( void jj_authority_apply_to_inputs( const jj_control_snapshot_t *authority, jj_inputs_t *inputs); +jj_outputs_t jj_authority_control_step( + jj_authority_t *state, + const jj_inputs_t *authoritative_inputs, + uint64_t now_ms, + jj_control_snapshot_t *out); const char *jj_control_result_str(jj_control_result_t result); const char *jj_mode_str(jj_mode_t mode); +const char *jj_control_loss_reason_str(jj_control_loss_reason_t reason); diff --git a/components/jj_authority/jj_authority.c b/components/jj_authority/jj_authority.c index 46fe8e4..4aa4aed 100644 --- a/components/jj_authority/jj_authority.c +++ b/components/jj_authority/jj_authority.c @@ -2,7 +2,6 @@ #include "jj_authority.h" #include -#include #include #ifdef ESP_PLATFORM @@ -18,7 +17,14 @@ static portMUX_TYPE s_authority_lock = portMUX_INITIALIZER_UNLOCKED; static void copy_text(char *destination, size_t size, const char *source) { if (!destination || size == 0) return; - snprintf(destination, size, "%s", source ? source : ""); + size_t i = 0; + if (source) { + while (i + 1 < size && source[i] != '\0') { + destination[i] = source[i]; + ++i; + } + } + destination[i] = '\0'; } static void advance(uint32_t *value) @@ -43,6 +49,21 @@ static void snapshot_unlocked( } } +/* + * Lock ordering is AUTHORITY_LOCK -> jj_interlock's state lock. Interlock code + * never calls back into jj_authority. Everything below this lock is bounded + * in-memory work: no logging, allocation, networking, or blocking services. + */ +static void remove_remote_authorization_unlocked( + jj_authority_t *state, + jj_control_authority_t authority, + jj_control_inhibit_t inhibit, + jj_block_reason_t reason) +{ + jj_interlock_remove_remote_authorization( + state->interlock, authority, inhibit, reason); +} + static bool lease_expire_unlocked(jj_authority_t *state, uint64_t now_ms) { if (!state->state.lease_active || now_ms < state->lease_deadline_ms) @@ -54,23 +75,29 @@ static bool lease_expire_unlocked(jj_authority_t *state, uint64_t now_ms) advance(&state->state.generation); advance(&state->state.revision); state->state.last_control_loss_ms = now_ms; - copy_text(state->state.last_control_loss_reason, - sizeof state->state.last_control_loss_reason, - "remote_lease_expired"); + state->state.last_control_loss_reason = + JJ_CONTROL_LOSS_REMOTE_LEASE_EXPIRED; if (state->state.mode == JJ_MODE_MANUAL) { state->state.manual_demand_authorized = false; state->state.active_authority = JJ_AUTHORITY_NONE; state->state.control_inhibit = JJ_CONTROL_INHIBIT_NO_AUTHORITY; + remove_remote_authorization_unlocked( + state, JJ_AUTHORITY_NONE, JJ_CONTROL_INHIBIT_NO_AUTHORITY, + JJ_BLOCK_CONTROL_NO_AUTHORITY); } return true; } -void jj_authority_init(jj_authority_t *state, uint32_t lease_ttl_ms) +void jj_authority_init( + jj_authority_t *state, + jj_interlock_t *interlock, + uint32_t lease_ttl_ms) { - if (!state) return; + if (!state || !interlock) return; AUTHORITY_LOCK(); memset(state, 0, sizeof *state); state->lease_ttl_ms = lease_ttl_ms ? lease_ttl_ms : JJ_REMOTE_LEASE_TTL_MS; + state->interlock = interlock; state->state.revision = 1; state->state.generation = 1; state->state.mode = JJ_MODE_OFF; @@ -114,12 +141,16 @@ jj_control_result_t jj_authority_acquire( state->state.active_authority = JJ_AUTHORITY_REACQUIRING; state->state.control_inhibit = JJ_CONTROL_INHIBIT_STATE_REFRESH_REQUIRED; + remove_remote_authorization_unlocked( + state, JJ_AUTHORITY_REACQUIRING, + JJ_CONTROL_INHIBIT_STATE_REFRESH_REQUIRED, + JJ_BLOCK_STATE_REFRESH_REQUIRED); } if (revoked_remote_demand || request->takeover) { state->state.last_control_loss_ms = now_ms; - copy_text(state->state.last_control_loss_reason, - sizeof state->state.last_control_loss_reason, - request->takeover ? "explicit_takeover" : "remote_reacquired"); + state->state.last_control_loss_reason = request->takeover + ? JJ_CONTROL_LOSS_EXPLICIT_TAKEOVER + : JJ_CONTROL_LOSS_REMOTE_REACQUIRED; } snapshot_unlocked(state, now_ms, out); AUTHORITY_UNLOCK(); @@ -179,6 +210,10 @@ jj_control_result_t jj_authority_refresh( state->state.active_authority = JJ_AUTHORITY_NONE; state->state.control_inhibit = state->state.mode == JJ_MODE_MANUAL ? JJ_CONTROL_INHIBIT_NO_AUTHORITY : JJ_CONTROL_INHIBIT_NONE; + remove_remote_authorization_unlocked( + state, JJ_AUTHORITY_NONE, state->state.control_inhibit, + state->state.mode == JJ_MODE_MANUAL + ? JJ_BLOCK_CONTROL_NO_AUTHORITY : JJ_BLOCK_OFF); advance(&state->state.revision); } snapshot_unlocked(state, now_ms, out); @@ -197,13 +232,12 @@ static bool sensors_valid(const jj_inputs_t *input) } static bool base_heat_eligible( - const jj_interlock_t *interlock, + jj_fault_t fault, const jj_inputs_t *input) { - const jj_outputs_t output = jj_interlock_snapshot(interlock); return input->commissioned && sensors_valid(input) && !input->overtemperature_detected && - output.fault == JJ_FAULT_NONE; + fault == JJ_FAULT_NONE; } static uint64_t request_signature(const jj_control_mutation_t *request) @@ -264,7 +298,8 @@ jj_control_result_t jj_authority_mutate( uint64_t now_ms, jj_control_snapshot_t *out) { - if (!state || !interlock || !request || !authoritative_inputs) + if (!state || !interlock || interlock != state->interlock || !request || + !authoritative_inputs) return JJ_CONTROL_INVALID; const uint64_t signature = request_signature(request); AUTHORITY_LOCK(); @@ -318,11 +353,13 @@ jj_control_result_t jj_authority_mutate( state->state.manual_demand_authorized = false; state->state.active_authority = JJ_AUTHORITY_NONE; state->state.control_inhibit = JJ_CONTROL_INHIBIT_NONE; + remove_remote_authorization_unlocked( + state, JJ_AUTHORITY_NONE, JJ_CONTROL_INHIBIT_NONE, JJ_BLOCK_OFF); } else if (request->kind == JJ_MUTATION_MANUAL) { const jj_outputs_t output = jj_interlock_snapshot(interlock); if (output.fault != JJ_FAULT_NONE) { result = JJ_CONTROL_HARDWARE_FAULT; - } else if (!base_heat_eligible(interlock, authoritative_inputs) || + } else if (!base_heat_eligible(output.fault, authoritative_inputs) || authoritative_inputs->fan_proof != JJ_FAN_PROOF_PROVEN || !isfinite(request->target_c) || request->target_c < JJ_MANUAL_TARGET_MIN_C || @@ -339,7 +376,7 @@ jj_control_result_t jj_authority_mutate( const jj_outputs_t output = jj_interlock_snapshot(interlock); if (output.fault != JJ_FAULT_NONE) { result = JJ_CONTROL_HARDWARE_FAULT; - } else if (!base_heat_eligible(interlock, authoritative_inputs) || + } else if (!base_heat_eligible(output.fault, authoritative_inputs) || authoritative_inputs->fan_proof != JJ_FAN_PROOF_PROVEN || !authoritative_inputs->printer.online || !authoritative_inputs->printer.printing || @@ -404,6 +441,25 @@ void jj_authority_apply_to_inputs( inputs->manual_demand_authorized = authority->manual_demand_authorized; } +jj_outputs_t jj_authority_control_step( + jj_authority_t *state, + const jj_inputs_t *authoritative_inputs, + uint64_t now_ms, + jj_control_snapshot_t *out) +{ + if (!state || !state->interlock || !authoritative_inputs) + return (jj_outputs_t){.fault = JJ_FAULT_SENSOR, + .block_reason = JJ_BLOCK_FAULT_LATCHED}; + jj_inputs_t input = *authoritative_inputs; + 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); + snapshot_unlocked(state, now_ms, out); + AUTHORITY_UNLOCK(); + return output; +} + const char *jj_control_result_str(jj_control_result_t result) { switch (result) { @@ -429,3 +485,15 @@ const char *jj_mode_str(jj_mode_t mode) default: return "unknown"; } } + +const char *jj_control_loss_reason_str(jj_control_loss_reason_t reason) +{ + switch (reason) { + case JJ_CONTROL_LOSS_NONE: return "none"; + case JJ_CONTROL_LOSS_REMOTE_LEASE_EXPIRED: + return "remote_lease_expired"; + case JJ_CONTROL_LOSS_EXPLICIT_TAKEOVER: return "explicit_takeover"; + case JJ_CONTROL_LOSS_REMOTE_REACQUIRED: return "remote_reacquired"; + default: return "unknown"; + } +} diff --git a/components/jj_interlock/include/jj_interlock.h b/components/jj_interlock/include/jj_interlock.h index 83605b5..dd76397 100644 --- a/components/jj_interlock/include/jj_interlock.h +++ b/components/jj_interlock/include/jj_interlock.h @@ -36,8 +36,15 @@ typedef enum { typedef enum { JJ_FAULT_NONE = 0, JJ_FAULT_SENSOR, JJ_FAULT_OVERTEMPERATURE, JJ_FAULT_FAN, JJ_FAULT_NO_HEAT, JJ_FAULT_UNCONTROLLED_RISE, - JJ_FAULT_CONFIG, + JJ_FAULT_CONFIG, JJ_FAULT_STUCK_ON, JJ_FAULT_COMMANDED_OFF_PROOF, + JJ_FAULT_WATCHDOG_RESET, JJ_FAULT_UNEXPECTED_RESET, + JJ_FAULT_BROWNOUT_RESET, } jj_fault_t; +typedef enum { + JJ_REMOTE_ACK_NEVER = 0, + JJ_REMOTE_ACK_WHEN_HEALTHY, + JJ_REMOTE_ACK_AFTER_REVALIDATION, +} jj_remote_ack_policy_t; typedef enum { JJ_BLOCK_NONE = 0, JJ_BLOCK_OFF, JJ_BLOCK_NOT_COMMISSIONED, JJ_BLOCK_FAULT_LATCHED, JJ_BLOCK_PRINTER_UNAVAILABLE, @@ -66,8 +73,11 @@ typedef struct { jj_sensor_sample_t outlet; jj_sensor_sample_t case_sensor; bool overtemperature_detected; + bool overtemperature_reset_proven; bool cooldown_required; bool fault_requires_thermal_management; + bool no_heat_revalidation_proven; + bool reset_revalidation_proven; jj_printer_sample_t printer; jj_fan_proof_t fan_proof; } jj_inputs_t; @@ -88,7 +98,13 @@ 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); bool jj_interlock_clear_fault(jj_interlock_t *state, const jj_inputs_t *input); +void jj_interlock_remove_remote_authorization( + jj_interlock_t *state, + jj_control_authority_t authority, + jj_control_inhibit_t inhibit, + jj_block_reason_t reason); jj_outputs_t jj_interlock_snapshot(const jj_interlock_t *state); +jj_remote_ack_policy_t jj_fault_remote_ack_policy(jj_fault_t fault); const char *jj_fault_str(jj_fault_t fault); const char *jj_block_reason_str(jj_block_reason_t reason); const char *jj_control_authority_str(jj_control_authority_t authority); diff --git a/components/jj_interlock/jj_interlock.c b/components/jj_interlock/jj_interlock.c index 2bcd38f..508ec41 100644 --- a/components/jj_interlock/jj_interlock.c +++ b/components/jj_interlock/jj_interlock.c @@ -178,6 +178,36 @@ static bool clear_fault_unlocked(jj_interlock_t *state, const jj_inputs_t *input input->overtemperature_detected || input->cooldown_required || input->fault_requires_thermal_management) return false; + if (jj_fault_remote_ack_policy(state->fault_latched) == + JJ_REMOTE_ACK_NEVER) + return false; + switch (state->fault_latched) { + case JJ_FAULT_SENSOR: + break; + case JJ_FAULT_OVERTEMPERATURE: + if (!input->overtemperature_reset_proven) return false; + break; + case JJ_FAULT_FAN: + if (input->fan_proof != JJ_FAN_PROOF_PROVEN) return false; + break; + case JJ_FAULT_NO_HEAT: + if (input->fan_proof != JJ_FAN_PROOF_PROVEN || + !input->no_heat_revalidation_proven) + return false; + break; + case JJ_FAULT_WATCHDOG_RESET: + case JJ_FAULT_UNEXPECTED_RESET: + case JJ_FAULT_BROWNOUT_RESET: + if (!input->reset_revalidation_proven) return false; + break; + case JJ_FAULT_NONE: + case JJ_FAULT_UNCONTROLLED_RISE: + case JJ_FAULT_CONFIG: + case JJ_FAULT_STUCK_ON: + case JJ_FAULT_COMMANDED_OFF_PROOF: + default: + return false; + } state->fault_latched = JJ_FAULT_NONE; state->last_output = (jj_outputs_t){.block_reason = JJ_BLOCK_OFF}; return true; @@ -192,6 +222,26 @@ bool jj_interlock_clear_fault(jj_interlock_t *state, const jj_inputs_t *input) return cleared; } +void jj_interlock_remove_remote_authorization( + jj_interlock_t *state, + jj_control_authority_t authority, + jj_control_inhibit_t inhibit, + jj_block_reason_t reason) +{ + if (!state) return; + STATE_LOCK(); + state->last_output.heater_requested = false; + state->last_output.effective_target_c = 0.0f; + state->last_output.active_authority = authority; + state->last_output.control_inhibit = inhibit; + state->last_output.block_reason = reason; + if (state->last_output.thermal_management_required) + state->last_output.thermal_state = JJ_THERMAL_COOLDOWN; + else + state->last_output.thermal_state = JJ_THERMAL_IDLE; + STATE_UNLOCK(); +} + jj_outputs_t jj_interlock_snapshot(const jj_interlock_t *state) { if (!state) @@ -213,10 +263,37 @@ const char *jj_fault_str(jj_fault_t fault) case JJ_FAULT_NO_HEAT: return "no_heat"; case JJ_FAULT_UNCONTROLLED_RISE: return "uncontrolled_rise"; case JJ_FAULT_CONFIG: return "config"; + case JJ_FAULT_STUCK_ON: return "stuck_on"; + case JJ_FAULT_COMMANDED_OFF_PROOF: return "commanded_off_proof"; + case JJ_FAULT_WATCHDOG_RESET: return "watchdog_reset"; + case JJ_FAULT_UNEXPECTED_RESET: return "unexpected_reset"; + case JJ_FAULT_BROWNOUT_RESET: return "brownout_reset"; default: return "unknown"; } } +jj_remote_ack_policy_t jj_fault_remote_ack_policy(jj_fault_t fault) +{ + switch (fault) { + case JJ_FAULT_SENSOR: + return JJ_REMOTE_ACK_WHEN_HEALTHY; + case JJ_FAULT_OVERTEMPERATURE: + case JJ_FAULT_FAN: + case JJ_FAULT_NO_HEAT: + case JJ_FAULT_WATCHDOG_RESET: + case JJ_FAULT_UNEXPECTED_RESET: + case JJ_FAULT_BROWNOUT_RESET: + return JJ_REMOTE_ACK_AFTER_REVALIDATION; + case JJ_FAULT_NONE: + case JJ_FAULT_UNCONTROLLED_RISE: + case JJ_FAULT_CONFIG: + case JJ_FAULT_STUCK_ON: + case JJ_FAULT_COMMANDED_OFF_PROOF: + default: + return JJ_REMOTE_ACK_NEVER; + } +} + const char *jj_block_reason_str(jj_block_reason_t reason) { switch (reason) { diff --git a/components/jj_portal/jj_portal.c b/components/jj_portal/jj_portal.c index a504cb2..8b9a04b 100644 --- a/components/jj_portal/jj_portal.c +++ b/components/jj_portal/jj_portal.c @@ -214,7 +214,8 @@ static cJSON *control_state_json( cJSON_AddNumberToObject(control, "last_loss_monotonic_ms", (double)authority->last_control_loss_ms); cJSON_AddStringToObject(control, "last_loss_reason", - authority->last_control_loss_reason); + jj_control_loss_reason_str( + authority->last_control_loss_reason)); } cJSON_AddNumberToObject(root, "configured_target_c", authority->configured_target_c); @@ -263,16 +264,6 @@ static cJSON *control_state_json( return root; } -static void evaluate_state( - const jj_control_snapshot_t *authority, - const dc_prusa_status_t *printer, - jj_outputs_t *output) -{ - jj_inputs_t input = authoritative_inputs(printer); - jj_authority_apply_to_inputs(authority, &input); - *output = jj_interlock_step(s_interlock, &input); -} - static esp_err_t state_get(httpd_req_t *req) { const uint64_t now_ms = monotonic_ms(); @@ -281,8 +272,7 @@ static esp_err_t state_get(httpd_req_t *req) jj_authority_snapshot(s_authority, now_ms, &authority); dc_prusa_status_t printer = {0}; (void)dc_prusa_get_status(&printer); - jj_outputs_t output; - evaluate_state(&authority, &printer, &output); + const jj_outputs_t output = jj_interlock_snapshot(s_interlock); return send_json(req, control_state_json( &authority, &output, &printer, false)); } @@ -329,8 +319,7 @@ static esp_err_t send_control_result( { dc_prusa_status_t printer = {0}; (void)dc_prusa_get_status(&printer); - jj_outputs_t output; - evaluate_state(authority, &printer, &output); + const jj_outputs_t output = jj_interlock_snapshot(s_interlock); cJSON *state = control_state_json( authority, &output, &printer, include_lease_id); if (result != JJ_CONTROL_OK) diff --git a/components/jj_portal/web/control-client.js b/components/jj_portal/web/control-client.js index 967ccb6..32a45eb 100644 --- a/components/jj_portal/web/control-client.js +++ b/components/jj_portal/web/control-client.js @@ -52,6 +52,12 @@ return messages[code] || "Heating inhibited: see advanced diagnostics"; } + function mutationErrorMessage(code) { + if (code === "state_revision_conflict") + return "Authoritative state changed. Refresh and verify the current result; the earlier request may have completed."; + return "Request rejected: " + code; + } + class Client { constructor(options) { this.fetch = options.fetch; @@ -193,5 +199,6 @@ CONTROL_LOSS_ERRORS, controllerLabel, constraintMessage, + mutationErrorMessage, }; }); diff --git a/components/jj_portal/web/control.html b/components/jj_portal/web/control.html index 16df72e..a27f006 100644 --- a/components/jj_portal/web/control.html +++ b/components/jj_portal/web/control.html @@ -117,7 +117,7 @@

Advanced control details

await client.mutate(button.dataset.action, target); $("message").textContent = "Server accepted the complete request."; } catch (error) { - $("message").textContent = "Request rejected: " + error.code; + $("message").textContent = JumpJetControl.mutationErrorMessage(error.code); } })); $("takeover").addEventListener("click", () => { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1b0589a..3e4d3e6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -66,6 +66,21 @@ fault thermal management, and authoritative overtemperature detection without inventing GPIOs or temperature thresholds. The production image has no physical output path, so API delivery remains zero/unavailable. +The control task is the sole production owner of ordinary interlock evaluation. +It first copies the independently polled, cached PrusaLink status, then performs +authority expiry/sampling, authority input application, and the final interlock +decision as one short ordered control step. The cached-status getter may wait on +its own mutex and therefore runs before authority is sampled; neither it nor any +network work runs under the authority `portMUX`. + +Authorization removal is directional and synchronous. Expiry, takeover, +reacquisition, and OFF immediately cold the current logical REMOTE decision, +without discarding cooldown/fan-afterrun state. Grants remain inert until the +next control step. Authority-to-interlock nesting always locks authority first; +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. + ## OTA invariant The portal rejects OTA when heat is requested or active thermal management is diff --git a/docs/CONTROL_AUTHORITY.md b/docs/CONTROL_AUTHORITY.md index f0ad6a8..24c1fdb 100644 --- a/docs/CONTROL_AUTHORITY.md +++ b/docs/CONTROL_AUTHORITY.md @@ -33,6 +33,8 @@ behavior. On foreground/resume the browser discards its authority assumption and repeats acquire plus refresh. Reconnection never restores a prior MANUAL request. +Internally, the last-loss reason is a typed enum; its stable wire mappings remain +`remote_lease_expired`, `explicit_takeover`, and `remote_reacquired`. ## Atomic mutations @@ -54,6 +56,13 @@ result with current authoritative state, while reuse with different content returns `control_request_conflict`. Historical state is never replayed as live control demand. +The cache is deliberately bounded. Once an entry has been evicted, a retry is a +newly evaluated request and may return `state_revision_conflict` even when the +original request committed. That response means “refresh authoritative state +and determine the current result”; it does not prove that the original mutation +failed. The browser accepts the returned authoritative state before surfacing +the conflict. + Stable machine-readable failures are: | Code | Meaning | @@ -86,3 +95,22 @@ diagnostics for tooling and troubleshooting. All control POST bodies must use `application/json`. General state GET responses never disclose the opaque lease ID. The dedicated control page receives it only from successful lease-bound responses. + +## Control-step ordering and directional revocation + +The control task reads `dc_prusa`'s cached status before sampling authority: + +`cached Prusa status -> expire/sample authority -> apply authority -> interlock` + +`dc_prusa_get_status()` does not perform network I/O, but it uses a mutex wait +with no formal finite bound. Sampling authority after that wait prevents it from +inflating the authority-sample-to-decision interval. No authority critical +section spans the cached-status read or any network operation. + +The safety invariant is directional, not a nominal loop-time promise. Lease +expiry, takeover, reacquisition, and OFF synchronously remove the current +logical REMOTE heater authorization and preserve any autonomous cooldown/fan +request. A new authority or Manual demand changes control state but does not +create interlock permission until the next authoritative control step. The +authority sample and interlock decision use the fixed lock order authority then +interlock; interlock code never calls back into authority. diff --git a/docs/PRODUCT_SAFETY_CONTRACT.md b/docs/PRODUCT_SAFETY_CONTRACT.md index 712ff79..1e385fc 100644 --- a/docs/PRODUCT_SAFETY_CONTRACT.md +++ b/docs/PRODUCT_SAFETY_CONTRACT.md @@ -52,6 +52,24 @@ the server commits the complete request or nothing. Stable failures are `control_request_ineligible`, `control_request_conflict`, and `hardware_fault_latched`. Reacquisition never acknowledges a real fault. +Remote acknowledgement is fault-class policy, not a blanket capability: + +| Fault class | Remote acknowledgement | Required authoritative evidence | +|---|---|---| +| sensor | `WHEN_HEALTHY` | Every required sensor reports a finite, valid, plausible sample with no open, short, or implausible status | +| overtemperature | `AFTER_REVALIDATION` | Firmware-proven reset condition below the validated hysteretic reset threshold, cooldown complete, and no fault thermal-management request | +| fan | `AFTER_REVALIDATION` | Actual fan/airflow proof is `PROVEN`; command alone is never proof | +| no heat | `AFTER_REVALIDATION` | Valid sensors, proven fan/airflow, heater OFF during recovery, and a completed fault-specific sanity sequence | +| uncontrolled rise | `NEVER` | Treated as suspected stuck-on behavior; ordinary remote clear is prohibited | +| configuration | `NEVER` | Configuration correction plus a service/reinitialization path | + +Stuck-on and commanded-OFF proof failures are separately reserved as `NEVER`. +Watchdog, unexpected/panic-reset, and brownout classes are separately reserved +as `AFTER_REVALIDATION`. Every new or unknown class defaults to `NEVER` until +reviewed. The browser can request acknowledgement but cannot provide any proof +bit. Hardware-dependent reset thresholds and proof procedures remain explicit +validation gates; their absence leaves the affected fault non-clearable. + ## AUTOMATIC and PrusaLink `dc_prusa` is read-only and owns status freshness. Jump Jet consumes its existing diff --git a/main/app_main.c b/main/app_main.c index 55cf0f5..2937dfa 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -34,11 +34,6 @@ static void control_task(void *arg) jj_block_reason_t previous = JJ_BLOCK_NONE; for (;;) { jj_inputs_t input = jj_inputs_safe_defaults(); - const uint64_t now_ms = (uint64_t)esp_timer_get_time() / 1000U; - (void)jj_authority_tick(&s_authority, now_ms); - jj_control_snapshot_t authority; - jj_authority_snapshot(&s_authority, now_ms, &authority); - jj_authority_apply_to_inputs(&authority, &input); dc_prusa_status_t printer = {0}; if (dc_prusa_get_status(&printer) == ESP_OK) { input.printer.online = printer.online; @@ -47,7 +42,13 @@ static void control_task(void *arg) // dc_prusa has already applied its authoritative 15-second // freshness rule. Jump Jet deliberately has no second timer. } - const jj_outputs_t output = jj_interlock_step(&s_interlock, &input); + /* + * Cached Prusa status is read before authority. The final authority + * sample and interlock decision are one short, ordered control step. + */ + 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); if (!startup_reported) { startup_reported = true; xTaskNotifyGive(startup_task); @@ -73,7 +74,7 @@ void app_main(void) } ESP_ERROR_CHECK(err); jj_interlock_init(&s_interlock); - jj_authority_init(&s_authority, JJ_REMOTE_LEASE_TTL_MS); + jj_authority_init(&s_authority, &s_interlock, JJ_REMOTE_LEASE_TTL_MS); BaseType_t created = xTaskCreate(control_task, "jj_control", 4096, xTaskGetCurrentTaskHandle(), 8, NULL); ESP_ERROR_CHECK(created == pdPASS ? ESP_OK : ESP_ERR_NO_MEM); diff --git a/tests/check_control_safety_contract.sh b/tests/check_control_safety_contract.sh new file mode 100755 index 0000000..3475330 --- /dev/null +++ b/tests/check_control_safety_contract.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +app="$root/main/app_main.c" +authority="$root/components/jj_authority/jj_authority.c" +portal="$root/components/jj_portal/jj_portal.c" +interlock="$root/components/jj_interlock/jj_interlock.c" + +prusa_line=$(grep -n -m1 'dc_prusa_get_status(&printer)' "$app" | cut -d: -f1) +control_line=$(grep -n -m1 'jj_authority_control_step(' "$app" | cut -d: -f1) +[ "$prusa_line" -lt "$control_line" ] + +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 + 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 + echo "authority critical-section unit contains forbidden unbounded work" >&2 + exit 1 +fi + +grep -q 'Lock ordering is AUTHORITY_LOCK -> jj_interlock' "$authority" +grep -q 'return JJ_REMOTE_ACK_WHEN_HEALTHY' "$interlock" +grep -q 'return JJ_REMOTE_ACK_AFTER_REVALIDATION' "$interlock" +grep -q 'return JJ_REMOTE_ACK_NEVER' "$interlock" + +echo "control ordering/locking/fault-policy contract check: PASS" diff --git a/tests/control-client.test.cjs b/tests/control-client.test.cjs index 0fa6a2e..6552739 100644 --- a/tests/control-client.test.cjs +++ b/tests/control-client.test.cjs @@ -10,6 +10,7 @@ const { ControlError, controllerLabel, constraintMessage, + mutationErrorMessage, } = require("../components/jj_portal/web/control-client.js"); function state(generation, revision, leaseId) { @@ -109,6 +110,25 @@ test("control loss disables mutation until reacquisition and refresh", async () assert.equal(calls.length, 3); }); +test("revision conflict adopts authoritative state without claiming original failure", async () => { + const calls = []; + const conflict = {...state(2, 7, "a".repeat(32)), mode: "off"}; + const fetch = scriptedFetch([ + response(state(2, 2, "a".repeat(32))), + response(state(2, 3, "a".repeat(32))), + response({error: "state_revision_conflict", state: conflict}, false), + ], calls); + const client = new Client({fetch, owner: "phone"}); + await client.resume(false); + await assert.rejects(client.mutate("manual", 45), error => + error instanceof ControlError && error.code === "state_revision_conflict"); + assert.equal(client.ready, true); + assert.equal(client.revision, 7); + assert.equal(client.state.mode, "off"); + assert.match(mutationErrorMessage("state_revision_conflict"), + /earlier request may have completed/); +}); + test("late mutation response from an older generation cannot replace current state", async () => { let resolveMutation; const mutationResponse = new Promise(resolve => { resolveMutation = resolve; }); diff --git a/tests/jj_authority_host_test.c b/tests/jj_authority_host_test.c index 6cae7ae..41c1f33 100644 --- a/tests/jj_authority_host_test.c +++ b/tests/jj_authority_host_test.c @@ -89,7 +89,7 @@ static void test_manual_expiry_fails_cold_without_fault_and_keeps_cooldown(void) { jj_authority_t authority; jj_interlock_t interlock; - jj_authority_init(&authority, 1000); + 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); @@ -100,14 +100,21 @@ static void test_manual_expiry_fails_cold_without_fault_and_keeps_cooldown(void) CHECK(evaluate(&interlock, &manual, inputs).heater_requested); CHECK(jj_authority_tick(&authority, 1000)); + jj_outputs_t output = jj_interlock_snapshot(&interlock); + CHECK(!output.heater_requested); + CHECK(output.thermal_state == JJ_THERMAL_COOLDOWN); jj_control_snapshot_t expired; jj_authority_snapshot(&authority, 1000, &expired); CHECK(expired.mode == JJ_MODE_MANUAL); CHECK(expired.active_authority == JJ_AUTHORITY_NONE); CHECK(expired.control_inhibit == JJ_CONTROL_INHIBIT_NO_AUTHORITY); CHECK(!expired.manual_demand_authorized); + CHECK(expired.last_control_loss_reason == + JJ_CONTROL_LOSS_REMOTE_LEASE_EXPIRED); + CHECK(strcmp(jj_control_loss_reason_str(expired.last_control_loss_reason), + "remote_lease_expired") == 0); inputs.cooldown_required = true; - jj_outputs_t output = evaluate(&interlock, &expired, inputs); + output = evaluate(&interlock, &expired, inputs); CHECK(!output.heater_requested); CHECK(output.fault == JJ_FAULT_NONE); CHECK(output.control_inhibit == JJ_CONTROL_INHIBIT_NO_AUTHORITY); @@ -119,7 +126,7 @@ static void test_reacquire_requires_refresh_and_new_explicit_request(void) { jj_authority_t authority; jj_interlock_t interlock; - jj_authority_init(&authority, 1000); + jj_authority_init(&authority, &interlock, 1000); jj_interlock_init(&interlock); jj_inputs_t inputs = nominal_inputs(); jj_control_snapshot_t first = acquire(&authority, LEASE_A, "phone", false, 0); @@ -165,7 +172,7 @@ static void test_stale_generation_and_revision_are_atomic(void) { jj_authority_t authority; jj_interlock_t interlock; - jj_authority_init(&authority, 1000); + 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", false, 0); @@ -195,13 +202,14 @@ static void test_explicit_takeover_revokes_old_remote_demand(void) { jj_authority_t authority; jj_interlock_t interlock; - jj_authority_init(&authority, 1000); + jj_authority_init(&authority, &interlock, 1000); jj_interlock_init(&interlock); jj_inputs_t inputs = nominal_inputs(); jj_control_snapshot_t first = acquire(&authority, LEASE_A, "phone-a", false, 0); 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); jj_control_acquire_t denied = {.takeover = false}; snprintf(denied.lease_id, sizeof denied.lease_id, "%s", LEASE_B); @@ -216,6 +224,11 @@ static void test_explicit_takeover_revokes_old_remote_demand(void) CHECK(takeover.generation > first.generation); CHECK(!takeover.manual_demand_authorized); CHECK(takeover.active_authority == JJ_AUTHORITY_REACQUIRING); + CHECK(takeover.last_control_loss_reason == + 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); jj_control_snapshot_t heartbeat; CHECK(jj_authority_heartbeat(&authority, LEASE_A, first.generation, 4, @@ -226,7 +239,7 @@ static void test_automatic_survives_browser_loss_but_eligibility_fails_cold(void { jj_authority_t authority; jj_interlock_t interlock; - jj_authority_init(&authority, 1000); + jj_authority_init(&authority, &interlock, 1000); jj_interlock_init(&interlock); jj_inputs_t inputs = nominal_inputs(); inputs.automatic_target_available = true; @@ -272,7 +285,7 @@ static void test_automatic_transition_requires_current_product_eligibility(void) { jj_authority_t authority; jj_interlock_t interlock; - jj_authority_init(&authority, 1000); + 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", false, 0); @@ -300,7 +313,7 @@ static void test_faults_are_not_cleared_by_authority_and_client_cannot_bypass(vo { jj_authority_t authority; jj_interlock_t interlock; - jj_authority_init(&authority, 1000); + jj_authority_init(&authority, &interlock, 1000); jj_interlock_init(&interlock); jj_inputs_t inputs = nominal_inputs(); jj_inputs_t faulted = inputs; @@ -338,7 +351,7 @@ static void test_request_uuid_retry_is_exactly_once_and_content_bound(void) { jj_authority_t authority; jj_interlock_t interlock; - jj_authority_init(&authority, 1000); + 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", false, 0); @@ -381,7 +394,7 @@ static void test_retry_after_expiry_never_replays_historical_manual_demand(void) { jj_authority_t authority; jj_interlock_t interlock; - jj_authority_init(&authority, 1000); + 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", false, 0); @@ -407,6 +420,140 @@ static void test_retry_after_expiry_never_replays_historical_manual_demand(void) CHECK(!evaluate(&interlock, &retry, inputs).heater_requested); } +static void test_directional_authority_sampling(void) +{ + jj_authority_t authority; + jj_interlock_t interlock; + jj_authority_init(&authority, &interlock, 1000); + jj_interlock_init(&interlock); + jj_inputs_t cached_prusa_inputs = nominal_inputs(); + jj_control_snapshot_t lease = acquire(&authority, LEASE_A, "phone-a", false, 0); + lease = refresh(&authority, &lease, 0); + jj_control_snapshot_t manual = mutate( + &authority, &interlock, &lease, JJ_MUTATION_MANUAL, 45.0f, + "directional-manual", &cached_prusa_inputs, 1); + + /* Grants do not rewrite the current interlock decision. */ + CHECK(!jj_interlock_snapshot(&interlock).heater_requested); + CHECK(jj_authority_control_step( + &authority, &cached_prusa_inputs, 2, &manual).heater_requested); + + /* 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_authority_control_step( + &authority, &cached_prusa_inputs, 3, NULL).heater_requested); + + 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_authority_control_step( + &authority, &cached_prusa_inputs, 5, NULL).heater_requested); + + jj_control_snapshot_t reacquiring = acquire( + &authority, LEASE_A, "phone-b", false, 6); + CHECK(reacquiring.last_control_loss_reason == + JJ_CONTROL_LOSS_REMOTE_REACQUIRED); + CHECK(strcmp(jj_control_loss_reason_str( + reacquiring.last_control_loss_reason), + "remote_reacquired") == 0); + CHECK(!jj_interlock_snapshot(&interlock).heater_requested); + + 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); + 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); +} + +static void test_evicted_retry_reports_revision_conflict_with_current_state(void) +{ + jj_authority_t authority; + jj_interlock_t interlock; + jj_authority_init(&authority, &interlock, 10000); + jj_interlock_init(&interlock); + jj_inputs_t inputs = nominal_inputs(); + jj_control_snapshot_t lease = acquire(&authority, LEASE_A, "phone", false, 0); + lease = refresh(&authority, &lease, 0); + jj_control_mutation_t original = { + .generation = lease.generation, + .expected_revision = lease.revision, + .kind = JJ_MUTATION_MANUAL, + .target_c = 45.0f, + }; + snprintf(original.lease_id, sizeof original.lease_id, "%s", LEASE_A); + snprintf(original.request_id, sizeof original.request_id, "%s", "original"); + jj_control_snapshot_t current; + CHECK(jj_authority_mutate(&authority, &interlock, &original, &inputs, 1, + ¤t) == JJ_CONTROL_OK); + + for (size_t i = 0; i < JJ_CONTROL_REQUEST_CACHE_SIZE; ++i) { + jj_control_mutation_t next = { + .generation = current.generation, + .expected_revision = current.revision, + .kind = i % 2 == 0 ? JJ_MUTATION_OFF : JJ_MUTATION_MANUAL, + .target_c = 45.0f, + }; + snprintf(next.lease_id, sizeof next.lease_id, "%s", LEASE_A); + snprintf(next.request_id, sizeof next.request_id, "evict-%zu", i); + CHECK(jj_authority_mutate(&authority, &interlock, &next, &inputs, + 2 + i, ¤t) == JJ_CONTROL_OK); + } + + jj_control_snapshot_t retry_state; + CHECK(jj_authority_mutate(&authority, &interlock, &original, &inputs, 20, + &retry_state) == JJ_CONTROL_REVISION_CONFLICT); + CHECK(retry_state.revision == current.revision); + CHECK(retry_state.mode == current.mode); +} + +static void test_remote_clear_obeys_fault_class_policy(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", false, 0); + lease = refresh(&authority, &lease, 0); + jj_control_mutation_t clear = { + .generation = lease.generation, + .expected_revision = lease.revision, + .kind = JJ_MUTATION_CLEAR_FAULT, + }; + snprintf(clear.lease_id, sizeof clear.lease_id, "%s", LEASE_A); + + interlock.fault_latched = JJ_FAULT_FAN; + inputs.fan_proof = JJ_FAN_PROOF_PENDING; + jj_control_snapshot_t result; + CHECK(jj_authority_mutate(&authority, &interlock, &clear, &inputs, 1, + &result) == JJ_CONTROL_HARDWARE_FAULT); + CHECK(interlock.fault_latched == JJ_FAULT_FAN); + inputs.fan_proof = JJ_FAN_PROOF_PROVEN; + CHECK(jj_authority_mutate(&authority, &interlock, &clear, &inputs, 2, + &result) == JJ_CONTROL_OK); + CHECK(interlock.fault_latched == JJ_FAULT_NONE); + + interlock.fault_latched = JJ_FAULT_UNCONTROLLED_RISE; + clear.expected_revision = result.revision; + inputs.overtemperature_reset_proven = true; + inputs.no_heat_revalidation_proven = true; + inputs.reset_revalidation_proven = true; + CHECK(jj_authority_mutate(&authority, &interlock, &clear, &inputs, 3, + &result) == JJ_CONTROL_HARDWARE_FAULT); + CHECK(interlock.fault_latched == JJ_FAULT_UNCONTROLLED_RISE); +} + int main(void) { test_manual_expiry_fails_cold_without_fault_and_keeps_cooldown(); @@ -418,6 +565,9 @@ int main(void) test_faults_are_not_cleared_by_authority_and_client_cannot_bypass(); test_request_uuid_retry_is_exactly_once_and_content_bound(); test_retry_after_expiry_never_replays_historical_manual_demand(); + test_directional_authority_sampling(); + test_evicted_retry_reports_revision_conflict_with_current_state(); + test_remote_clear_obeys_fault_class_policy(); puts("jj_authority_host_test: PASS"); return 0; } diff --git a/tests/jj_interlock_host_test.c b/tests/jj_interlock_host_test.c index e2618ca..c75a00b 100644 --- a/tests/jj_interlock_host_test.c +++ b/tests/jj_interlock_host_test.c @@ -173,6 +173,80 @@ static void test_fan_proof_and_null_inputs_fail_cold(void) check_cold(jj_interlock_step(&state, NULL), JJ_BLOCK_FAULT_LATCHED); } +static void test_remote_fault_acknowledgement_policy(void) +{ + CHECK(jj_fault_remote_ack_policy(JJ_FAULT_SENSOR) == + JJ_REMOTE_ACK_WHEN_HEALTHY); + CHECK(jj_fault_remote_ack_policy(JJ_FAULT_OVERTEMPERATURE) == + JJ_REMOTE_ACK_AFTER_REVALIDATION); + CHECK(jj_fault_remote_ack_policy(JJ_FAULT_FAN) == + JJ_REMOTE_ACK_AFTER_REVALIDATION); + CHECK(jj_fault_remote_ack_policy(JJ_FAULT_NO_HEAT) == + JJ_REMOTE_ACK_AFTER_REVALIDATION); + CHECK(jj_fault_remote_ack_policy(JJ_FAULT_WATCHDOG_RESET) == + JJ_REMOTE_ACK_AFTER_REVALIDATION); + CHECK(jj_fault_remote_ack_policy(JJ_FAULT_UNEXPECTED_RESET) == + JJ_REMOTE_ACK_AFTER_REVALIDATION); + CHECK(jj_fault_remote_ack_policy(JJ_FAULT_BROWNOUT_RESET) == + JJ_REMOTE_ACK_AFTER_REVALIDATION); + CHECK(jj_fault_remote_ack_policy(JJ_FAULT_UNCONTROLLED_RISE) == + JJ_REMOTE_ACK_NEVER); + CHECK(jj_fault_remote_ack_policy(JJ_FAULT_CONFIG) == JJ_REMOTE_ACK_NEVER); + CHECK(jj_fault_remote_ack_policy(JJ_FAULT_STUCK_ON) == JJ_REMOTE_ACK_NEVER); + CHECK(jj_fault_remote_ack_policy(JJ_FAULT_COMMANDED_OFF_PROOF) == + JJ_REMOTE_ACK_NEVER); + CHECK(jj_fault_remote_ack_policy((jj_fault_t)999) == JJ_REMOTE_ACK_NEVER); + + jj_interlock_t state; + jj_interlock_init(&state); + jj_inputs_t input = nominal(JJ_MODE_OFF); + + state.fault_latched = JJ_FAULT_SENSOR; + CHECK(jj_interlock_clear_fault(&state, &input)); + + state.fault_latched = JJ_FAULT_SENSOR; + input.chamber.status = JJ_SENSOR_IMPLAUSIBLE; + CHECK(!jj_interlock_clear_fault(&state, &input)); + input.chamber.status = JJ_SENSOR_OPEN; + CHECK(!jj_interlock_clear_fault(&state, &input)); + input.chamber.status = JJ_SENSOR_SHORT; + CHECK(!jj_interlock_clear_fault(&state, &input)); + input.chamber.status = JJ_SENSOR_OK; + input.chamber.temperature_c = NAN; + CHECK(!jj_interlock_clear_fault(&state, &input)); + input.chamber.temperature_c = 30.0f; + + state.fault_latched = JJ_FAULT_FAN; + input.fan_proof = JJ_FAN_PROOF_PENDING; + CHECK(!jj_interlock_clear_fault(&state, &input)); + input.fan_proof = JJ_FAN_PROOF_PROVEN; + CHECK(jj_interlock_clear_fault(&state, &input)); + + state.fault_latched = JJ_FAULT_OVERTEMPERATURE; + CHECK(!jj_interlock_clear_fault(&state, &input)); + input.overtemperature_reset_proven = true; + input.cooldown_required = true; + CHECK(!jj_interlock_clear_fault(&state, &input)); + input.cooldown_required = false; + CHECK(jj_interlock_clear_fault(&state, &input)); + + state.fault_latched = JJ_FAULT_NO_HEAT; + CHECK(!jj_interlock_clear_fault(&state, &input)); + input.no_heat_revalidation_proven = true; + CHECK(jj_interlock_clear_fault(&state, &input)); + + state.fault_latched = JJ_FAULT_UNCONTROLLED_RISE; + input.reset_revalidation_proven = true; + CHECK(!jj_interlock_clear_fault(&state, &input)); + state.fault_latched = JJ_FAULT_CONFIG; + CHECK(!jj_interlock_clear_fault(&state, &input)); + state.fault_latched = (jj_fault_t)999; + CHECK(!jj_interlock_clear_fault(&state, &input)); + + state.fault_latched = JJ_FAULT_WATCHDOG_RESET; + CHECK(jj_interlock_clear_fault(&state, &input)); +} + int main(void) { test_boot_defaults_and_modes(); @@ -182,6 +256,7 @@ int main(void) test_faults_latch_and_need_explicit_safe_clear(); test_off_keeps_thermal_management_request(); test_fan_proof_and_null_inputs_fail_cold(); + test_remote_fault_acknowledgement_policy(); puts("jj_interlock_host_test: PASS"); return 0; } From 36ce92dd739fc988df8caccb97ef1131f1f5dc24 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 17 Sep 2026 13:44:12 +0000 Subject: [PATCH 4/5] fix(control): require X-Dragon-Auth on lease/mutation endpoints acquire, refresh, heartbeat, and mutate were registered without the same authorize() gate every other mutating dc_portal/jj_portal route uses, so anyone reachable on the LAN could take control authority and issue mutations with no credential. Add the check to all four handlers and extend check_control_safety_contract.sh so a missing gate on any of them fails CI instead of shipping silently again. --- components/jj_portal/jj_portal.c | 14 ++++++++++++++ tests/check_control_safety_contract.sh | 12 ++++++++++++ 2 files changed, 26 insertions(+) diff --git a/components/jj_portal/jj_portal.c b/components/jj_portal/jj_portal.c index 8b9a04b..6720077 100644 --- a/components/jj_portal/jj_portal.c +++ b/components/jj_portal/jj_portal.c @@ -18,6 +18,8 @@ #define JJ_CONTROL_BODY_MAX 2048 +static bool authorize(httpd_req_t *req, void *ctx); + static const char *TAG = "jj_portal"; static jj_interlock_t *s_interlock; static jj_authority_t *s_authority; @@ -84,6 +86,14 @@ static esp_err_t send_error( return send_json(req, root); } +static esp_err_t send_unauthorized(httpd_req_t *req) +{ + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "error", "unauthorized"); + httpd_resp_set_status(req, "403 Forbidden"); + return send_json(req, root); +} + static cJSON *read_json_body(httpd_req_t *req) { char content_type[48]; @@ -329,6 +339,7 @@ static esp_err_t send_control_result( static esp_err_t acquire_post(httpd_req_t *req) { + if (!authorize(req, NULL)) return send_unauthorized(req); cJSON *json = read_json_body(req); jj_control_acquire_t request = {0}; const cJSON *takeover = json @@ -363,6 +374,7 @@ static bool parse_lease_request( static esp_err_t refresh_post(httpd_req_t *req) { + if (!authorize(req, NULL)) return send_unauthorized(req); cJSON *json = read_json_body(req); char lease_id[JJ_CONTROL_LEASE_ID_LEN + 1] = {0}; uint32_t generation = 0; @@ -380,6 +392,7 @@ static esp_err_t refresh_post(httpd_req_t *req) static esp_err_t heartbeat_post(httpd_req_t *req) { + if (!authorize(req, NULL)) return send_unauthorized(req); cJSON *json = read_json_body(req); char lease_id[JJ_CONTROL_LEASE_ID_LEN + 1] = {0}; uint32_t generation = 0; @@ -429,6 +442,7 @@ static bool parse_mutation( static esp_err_t mutate_post(httpd_req_t *req) { + if (!authorize(req, NULL)) return send_unauthorized(req); cJSON *json = read_json_body(req); jj_control_mutation_t request = {0}; if (!json || !parse_mutation(json, &request)) { diff --git a/tests/check_control_safety_contract.sh b/tests/check_control_safety_contract.sh index 3475330..55b12d4 100755 --- a/tests/check_control_safety_contract.sh +++ b/tests/check_control_safety_contract.sh @@ -31,4 +31,16 @@ grep -q 'return JJ_REMOTE_ACK_WHEN_HEALTHY' "$interlock" grep -q 'return JJ_REMOTE_ACK_AFTER_REVALIDATION' "$interlock" grep -q 'return JJ_REMOTE_ACK_NEVER' "$interlock" +for fn in acquire_post refresh_post heartbeat_post mutate_post; do + body=$(awk -v fn="$fn" ' + $0 ~ "^static esp_err_t "fn"\\(" { capture=1 } + capture { print } + capture && /^}/ { exit } + ' "$portal") + if ! printf '%s\n' "$body" | grep -q 'authorize(req'; then + echo "control handler $fn must call authorize(req) before mutating authority state" >&2 + exit 1 + fi +done + echo "control ordering/locking/fault-policy contract check: PASS" From 6bcd3fc41a4cf55bf7109c7fff321dd159baccf5 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 17 Sep 2026 13:53:23 +0000 Subject: [PATCH 5/5] revert(automatic): keep AUTOMATIC heat-delivery unconditionally cold The bed-target-to-chamber-target policy is its own Phase 4 gate per ROADMAP.md and PRODUCT_SAFETY_CONTRACT.md: it must be derived and reviewed on its own, not inferred, and AUTOMATIC production heating stays unavailable until it exists. That heat-delivery path had started riding in under this control-authority PR, gated only on automatic_target_available/automatic_target_c (fields nothing in production sets yet, but a real live wire once something does). Restores the interlock's AUTOMATIC branch to an unconditional JJ_BLOCK_AUTO_POLICY_UNAVAILABLE, matching main's original invariant, while keeping the new authority check (JJ_BLOCK_AUTO_AUTHORITY_UNAVAILABLE) ahead of it, since who holds automatic authority is legitimately this PR's scope. jj_authority.c's own AUTOMATIC-mutation eligibility check is left as-is; it only gates whether the mode switch itself is accepted, never whether heat is delivered, so it doesn't carry the same risk. The removed heat-delivery logic and its test coverage are preserved verbatim on feat/automatic-bed-target-policy for its own PR once the Phase 4 policy is actually derived. --- components/jj_interlock/jj_interlock.c | 21 +++------------------ tests/jj_authority_host_test.c | 10 +++++++--- tests/jj_interlock_host_test.c | 6 +++--- 3 files changed, 13 insertions(+), 24 deletions(-) diff --git a/components/jj_interlock/jj_interlock.c b/components/jj_interlock/jj_interlock.c index 508ec41..df83071 100644 --- a/components/jj_interlock/jj_interlock.c +++ b/components/jj_interlock/jj_interlock.c @@ -119,24 +119,9 @@ static jj_outputs_t step_unlocked(jj_interlock_t *state, const jj_inputs_t *inpu 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. */ - if (!input->automatic_target_available || - !isfinite(input->automatic_target_c) || - input->automatic_target_c <= 0.0f) - return blocked(state, JJ_BLOCK_AUTO_POLICY_UNAVAILABLE, input); - const jj_outputs_t output = { - .heater_requested = true, - .fan_percent = 100, - .effective_target_c = input->automatic_target_c, - .thermal_management_required = true, - .active_authority = JJ_AUTHORITY_AUTOMATIC, - .control_inhibit = JJ_CONTROL_INHIBIT_NONE, - .thermal_state = JJ_THERMAL_HEATING, - .fault = JJ_FAULT_NONE, - .block_reason = JJ_BLOCK_NONE, - }; - state->last_output = output; - return output; + /* 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); } if (input->active_authority == JJ_AUTHORITY_REACQUIRING || input->control_inhibit == JJ_CONTROL_INHIBIT_STATE_REFRESH_REQUIRED) diff --git a/tests/jj_authority_host_test.c b/tests/jj_authority_host_test.c index 41c1f33..8a65fdc 100644 --- a/tests/jj_authority_host_test.c +++ b/tests/jj_authority_host_test.c @@ -249,17 +249,21 @@ static void test_automatic_survives_browser_loss_but_eligibility_fails_cold(void jj_control_snapshot_t automatic = mutate( &authority, &interlock, &lease, JJ_MUTATION_AUTOMATIC, 0.0f, "auto-a", &inputs, 1); - CHECK(evaluate(&interlock, &automatic, inputs).heater_requested); + jj_outputs_t output = evaluate(&interlock, &automatic, inputs); + CHECK(!output.heater_requested); + CHECK(output.block_reason == JJ_BLOCK_AUTO_POLICY_UNAVAILABLE); CHECK(jj_authority_tick(&authority, 1000)); jj_control_snapshot_t expired; jj_authority_snapshot(&authority, 1000, &expired); CHECK(expired.mode == JJ_MODE_AUTOMATIC); CHECK(expired.active_authority == JJ_AUTHORITY_AUTOMATIC); - CHECK(evaluate(&interlock, &expired, inputs).heater_requested); + output = evaluate(&interlock, &expired, inputs); + CHECK(!output.heater_requested); + CHECK(output.block_reason == JJ_BLOCK_AUTO_POLICY_UNAVAILABLE); inputs.printer.online = false; - jj_outputs_t output = evaluate(&interlock, &expired, inputs); + output = evaluate(&interlock, &expired, inputs); CHECK(!output.heater_requested); CHECK(output.fault == JJ_FAULT_NONE); CHECK(output.block_reason == JJ_BLOCK_PRINTER_UNAVAILABLE); diff --git a/tests/jj_interlock_host_test.c b/tests/jj_interlock_host_test.c index c75a00b..63da916 100644 --- a/tests/jj_interlock_host_test.c +++ b/tests/jj_interlock_host_test.c @@ -100,9 +100,9 @@ static void test_automatic_authority_and_eligibility_are_independent(void) input.automatic_target_available = true; input.automatic_target_c = 42.0f; jj_outputs_t output = step_once(input); - CHECK(output.heater_requested); - CHECK(output.active_authority == JJ_AUTHORITY_AUTOMATIC); - CHECK(output.thermal_state == JJ_THERMAL_HEATING); + check_cold(output, JJ_BLOCK_AUTO_POLICY_UNAVAILABLE); /* fully eligible; still + cold until the bed- + target policy exists */ input.active_authority = JJ_AUTHORITY_NONE; check_cold(step_once(input), JJ_BLOCK_AUTO_AUTHORITY_UNAVAILABLE);