/*
EXERCISE 21 - SIMULATED RESISTANCE INPUTS (COIL AND CONTACTS)
Board: STM32 Nucleo-64 C031C6
PA0 START (active LOW), PA1 simulated feedback (active LOW), PA5 LED.
Serial Monitor: 115200 baud.
H = history, E = CSV export, V = validate records, C = CSV field-count check
L = list relay profiles, N = next profile, P = previous profile
K<ohm> = coil resistance, F<mohm> = front contact, B<mohm> = back contact
R = show the simulated resistance inputs (example: K1500 F50 B120)
SOFTWARE DEMONSTRATION ONLY:
- No real relay measurements are performed.
- The coil voltage is a SOFTWARE-GENERATED ramp, not a real voltage.
- Pick-up and drop-away are captured from the BLUE feedback button.
- Operate and release times are captured from the BLUE button edges
with microsecond timestamps and shown in whole milliseconds.
- Coil and front/back contact resistance are VALUES YOU TYPE; they are
not measured. Ranges follow the design document (Para 7).
- Acceptance limits are unverified and not applied.
- History is RAM-only and lost on reset/restart.
- This sketch does not claim RDSO compliance.
*/
#include <Arduino.h>
#include <string.h>
const uint8_t START_PIN = PA0;
const uint8_t FEEDBACK_PIN = PA1;
const uint8_t RELAY_LED_PIN = PA5;
const unsigned long TEST_TIMEOUT_MS = 3000;
const unsigned long REQUIRED_HOLD_MS = 500;
const unsigned long DEBOUNCE_MS = 30;
const uint8_t HISTORY_SIZE = 10;
const uint8_t CSV_COLUMN_COUNT = 33;
const size_t CSV_ROW_BUFFER_SIZE = 512;
// Simulated coil-voltage ramp. Values are in centivolts (1 = 0.01 V) so the
// 0.01 V resolution of Para 7 is kept without any floating-point maths.
// DEMO ramp rate = 5 V/s so a simulation run is short. The PCB design
// document uses 1 V/s; change RAMP_CV_PER_S to 100 for that rate.
const long RAMP_CV_PER_S = 500;
const long MAX_CV = 6000; // 60.00 V upper end of Para 7 range
const long PRINT_STEP_CV = 500; // progress line every 5.00 V
// Timing part of the test
const unsigned long SETTLE_MS = 500; // coil off, button released
const unsigned long HOLD_MS = 1000; // coil on, relay held operated
const unsigned long TIMING_TIMEOUT_MS = 3000; // max wait for operate / release
// Resistance ranges (design document, Para 7). Contacts are kept in milliohm.
const long COIL_MAX_OHM = 5000; // 0-5000 ohm, 1 ohm resolution
const long CONTACT_MAX_MOHM = 5000; // 0-5 ohm, 1 mohm resolution
const long DRY_CIRCUIT_MAX_MOHM = 200; // 20 mV rule applies up to 200 mohm only
const char SOFTWARE_VERSION[] = "Exercise21-v1.0";
const char RELAY_CODE[] = "ABCDE"; // placeholder until barcode input exists
enum TesterState {
STATE_SELF_CHECK,
STATE_READY,
STATE_RUNNING,
STATE_EVALUATING,
STATE_RECORDING,
STATE_COMPLETE_PASS,
STATE_COMPLETE_FAIL,
STATE_SOFTWARE_FAULT
};
TesterState testerState = STATE_SELF_CHECK;
const char *stateName(TesterState s) {
switch (s) {
case STATE_SELF_CHECK: return "SELF CHECK";
case STATE_READY: return "READY";
case STATE_RUNNING: return "RUNNING";
case STATE_EVALUATING: return "EVALUATING";
case STATE_RECORDING: return "RECORDING";
case STATE_COMPLETE_PASS: return "COMPLETE PASS";
case STATE_COMPLETE_FAIL: return "COMPLETE FAIL";
case STATE_SOFTWARE_FAULT: return "SOFTWARE FAULT";
default: return "UNKNOWN";
}
}
void setState(TesterState s) {
testerState = s;
Serial.print("STATE: ");
Serial.println(stateName(s));
}
enum {
MEAS_PICKUP,
MEAS_DROPAWAY,
MEAS_COIL,
MEAS_FRONT,
MEAS_BACK,
MEAS_OPERATE,
MEAS_RELEASE,
MEAS_COUNT
};
struct MeasurementDefinition {
const char *name;
const char *unit;
};
const MeasurementDefinition MEAS[MEAS_COUNT] = {
{"PickupVoltage", "V"},
{"DropAwayVoltage", "V"},
{"CoilResistance", "ohm"},
{"FrontContactResistance", "ohm"},
{"BackContactResistance", "ohm"},
{"OperateTime", "ms"},
{"ReleaseTime", "ms"}
};
// ---------------- Relay profile table (design document, Section 7) -------
// Only the data listed in the document is stored. Where the document gives
// no rating, the text NOT LISTED is used. No tolerance limits are stored.
struct RelayProfile {
const char *type;
const char *rating;
const char *contacts;
};
const uint8_t PROFILE_COUNT = 30;
const RelayProfile PROFILES[PROFILE_COUNT] = {
{"QECX", "NOT LISTED", "4F-4B"},
{"QTA2", "9 ohm", "2F-1B"},
{"QSPA1", "NOT LISTED", "8F-4B"},
{"QBA1", "24 V DC", "8F-8B"},
{"QBCA1", "24 V DC", "2HF-4B"},
{"QNN1", "24 V DC", "6F-2B & 6F-2B"},
{"QNN1", "24 V DC", "4F-4B & 4F-4B"},
{"QNNA1", "24 V DC", "6F-2B & 6F-2B"},
{"QNNA1", "24 V DC", "4F-4B & 4F-4B"},
{"QN1K", "NOT LISTED", "6F-6B"},
{"QN1K", "NOT LISTED", "4F-4B"},
{"QNA1K", "NOT LISTED", "6F-6B"},
{"QNA1K", "NOT LISTED", "4F-4B"},
{"QBAT", "9 ohm", "2F-2B"},
{"QL1", "24 V DC", "11F-4B"},
{"QSRA1", "NOT LISTED", "8F-4B"},
{"QN1", "24 V DC", "8F-8B"},
{"QN1", "24 V DC", "12F-4B"},
{"QNA1", "24 V DC", "12F-4B"},
{"QN1K", "NOT LISTED", "8F-8B"},
{"QN1K", "NOT LISTED", "12F-4B"},
{"QT2", "4 ohm", "2F-1B"},
{"QT2", "9 ohm", "2F-2B"},
{"QS-3", "12 V DC", "4F-4B"},
{"QCJ1", "24 V DC", "2F-1B"},
{"QB3", "12 V DC", "4F-2B"},
{"QN1 50V", "50 V", "8F-8B"},
{"QN1 50V", "50 V", "12F-4B"},
{"QNN1 50V", "50 V", "6F-2B & 6F-2B"},
{"QNN1 50V", "50 V", "4F-4B & 4F-4B"}
};
uint8_t selectedProfile = 17; // index 17 = QN1, 24 V DC, 12F-4B
struct MeasurementResult {
bool available;
long value; // centivolts for voltages; whole units for times
const char *source;
const char *status;
};
struct TestRecord {
unsigned long testNumber;
unsigned long sessionElapsedMs;
bool passed;
uint8_t profileIndex;
long feedbackStartMs;
unsigned long feedbackHoldMs;
char reason[80];
MeasurementResult measurements[MEAS_COUNT];
};
// Function prototypes must be after the custom structure definitions.
// Every function that takes TestRecord or MeasurementResult needs one here.
void clearMeasurements(MeasurementResult m[]);
void printMeasurements(const TestRecord &r);
void printHistory();
void exportCSV();
void validateHistory();
size_t countCSVFields(const char *line);
void validateCSVFieldCounts();
bool validateRecord(const TestRecord &r);
bool buildCSVRecord(const TestRecord &r, char *row, size_t capacity);
void finishTest(bool passed, const char *reason, long feedbackStart,
unsigned long holdMs);
TestRecord history[HISTORY_SIZE];
uint8_t historyCount = 0;
uint8_t nextHistoryIndex = 0;
unsigned long totalTests = 0;
unsigned long totalPass = 0;
unsigned long totalFail = 0;
unsigned long nextTestNumber = 1;
unsigned long sessionStartMs = 0;
void clearMeasurements(MeasurementResult m[]) {
for (uint8_t i = 0; i < MEAS_COUNT; i++) {
m[i].available = false;
m[i].value = 0;
m[i].source = "UNAVAILABLE";
m[i].status = "NOT_AVAILABLE";
}
}
void copyText(char *dst, size_t n, const char *src) {
if (n == 0) return;
strncpy(dst, src, n - 1);
dst[n - 1] = '\0';
}
void separator() {
Serial.println("-----------------------------------------------");
}
void setDebounced(uint8_t pin, int &rawLast, int &stable,
unsigned long &changedAt, unsigned long now) {
int raw = digitalRead(pin);
if (raw != rawLast) {
rawLast = raw;
changedAt = now;
}
if (now - changedAt >= DEBOUNCE_MS) {
stable = raw;
}
}
int startRawLast = HIGH;
int startStable = HIGH;
int feedbackRawLast = HIGH;
int feedbackStable = HIGH;
unsigned long startChangedAt = 0;
unsigned long feedbackChangedAt = 0;
bool testInProgress = false;
bool feedbackTimingStarted = false;
unsigned long testStartMs = 0;
unsigned long feedbackStartAt = 0;
unsigned long feedbackHoldMs = 0;
// Ramp-test state
enum RampPhase {
PHASE_IDLE, PHASE_UP, PHASE_DOWN,
PHASE_SETTLE, PHASE_OPERATE, PHASE_HOLD, PHASE_RELEASE
};
RampPhase rampPhase = PHASE_IDLE;
bool gotPickup = false;
bool gotDropAway = false;
long pickupCV = 0;
long dropAwayCV = 0;
unsigned long pickupAtMs = 0; // ms after test start
unsigned long downStartMs = 0;
long lastPrintedCV = -1;
// Timing-part state
bool gotOperate = false;
bool gotRelease = false;
long operateMs = 0;
long releaseMs = 0;
unsigned long pickupToDropMs = 0;
unsigned long phaseStartMs = 0;
unsigned long coilOnUs = 0;
unsigned long coilOffUs = 0;
unsigned long lastRawLowUs = 0;
unsigned long lastRawHighUs = 0;
int rawTrackPrev = HIGH;
// Simulated resistance inputs (typed by the operator; placeholders until set).
long simCoilOhm = 1000;
long simFrontMohm = 50;
long simBackMohm = 50;
long measCoilOhm = 0;
long measFrontMohm = 0;
long measBackMohm = 0;
bool gotCoil = false;
bool gotContacts = false;
// Serial number entry (K, F, B commands)
char pendingCmd = 0;
long pendingVal = 0;
uint8_t pendingDigits = 0;
unsigned long lastCharMs = 0;
// Small number-to-text helpers. They replace snprintf/float, which would not
// fit in the 32 KB flash of the STM32C031C6.
// Writes an unsigned number and returns the number of characters written.
size_t writeUnsigned(char *buf, size_t n, unsigned long v) {
char tmp[12];
size_t len = 0;
do {
tmp[len++] = (char)('0' + (v % 10UL));
v /= 10UL;
} while (v > 0 && len < sizeof(tmp));
size_t out = 0;
while (len > 0 && out + 1 < n) buf[out++] = tmp[--len];
buf[out] = '\0';
return out;
}
// Write centivolts as text like 24.50.
void formatCV(char *buf, size_t n, long cv) {
if (n < 8) { if (n > 0) buf[0] = '\0'; return; }
if (cv < 0) cv = 0;
size_t p = writeUnsigned(buf, n - 4, (unsigned long)(cv / 100));
buf[p++] = '.';
buf[p++] = (char)('0' + (cv % 100) / 10);
buf[p++] = (char)('0' + (cv % 10));
buf[p] = '\0';
}
// Write milliohm as ohm text with 3 decimals, like 0.050.
void formatMilli(char *buf, size_t n, long mv) {
if (n < 10) { if (n > 0) buf[0] = '\0'; return; }
if (mv < 0) mv = 0;
size_t p = writeUnsigned(buf, n - 5, (unsigned long)(mv / 1000));
buf[p++] = '.';
buf[p++] = (char)('0' + (mv / 100) % 10);
buf[p++] = (char)('0' + (mv / 10) % 10);
buf[p++] = (char)('0' + (mv % 10));
buf[p] = '\0';
}
// Text for any measurement: voltages 0.01 V, contacts 0.001 ohm, others whole.
void formatMeasurement(uint8_t idx, long value, char *buf, size_t n) {
if (idx == MEAS_PICKUP || idx == MEAS_DROPAWAY) {
formatCV(buf, n, value);
} else if (idx == MEAS_FRONT || idx == MEAS_BACK) {
formatMilli(buf, n, value);
} else {
writeUnsigned(buf, n, (unsigned long)(value < 0 ? 0 : value));
}
}
// ---------------- Profile display and selection ---------------------------
void printProfile() {
separator();
Serial.print("RELAY PROFILE ");
Serial.print(selectedProfile + 1);
Serial.print(" of ");
Serial.println(PROFILE_COUNT);
Serial.print("Type: "); Serial.println(PROFILES[selectedProfile].type);
Serial.print("Rating as listed: "); Serial.println(PROFILES[selectedProfile].rating);
Serial.print("Contacts: "); Serial.println(PROFILES[selectedProfile].contacts);
Serial.print("Relay code: "); Serial.println(RELAY_CODE);
Serial.println("Acceptance limits: UNVERIFIED / NOT APPLIED");
separator();
}
void listProfiles() {
separator();
Serial.println("SUPPORTED RELAY VARIANTS (design document, Section 7)");
for (uint8_t i = 0; i < PROFILE_COUNT; i++) {
Serial.print(i + 1);
Serial.print(i == selectedProfile ? " * " : " ");
Serial.print(PROFILES[i].type);
Serial.print(" | ");
Serial.print(PROFILES[i].rating);
Serial.print(" | ");
Serial.println(PROFILES[i].contacts);
}
Serial.println("* = currently selected");
separator();
}
void changeProfile(int step) {
if (testerState != STATE_READY) {
Serial.println("Profile can be changed only in READY state.");
return;
}
selectedProfile = (uint8_t)((selectedProfile + step + PROFILE_COUNT) % PROFILE_COUNT);
printProfile();
}
void printInputs() {
char v[16];
separator();
Serial.println("SIMULATED RESISTANCE INPUTS (typed values, not measured)");
Serial.print("Coil: "); Serial.print(simCoilOhm);
Serial.println(" ohm [0-5000 ohm, 1 ohm steps]");
formatMilli(v, sizeof(v), simFrontMohm);
Serial.print("Front contact: "); Serial.print(v);
formatMilli(v, sizeof(v), simBackMohm);
Serial.print(" ohm Back contact: "); Serial.print(v);
Serial.println(" ohm [0-5 ohm, 0.001 ohm steps]");
Serial.println("Set with: K<ohm> F<milliohm> B<milliohm> e.g. K1500 F50 B120");
separator();
}
void setSimValue(char cmd, long val) {
if (testerState != STATE_READY) {
Serial.println("Inputs can be changed only in READY state.");
return;
}
if (cmd == 'K') {
if (val > COIL_MAX_OHM) {
Serial.println("REJECTED: coil resistance range is 0-5000 ohm.");
return;
}
simCoilOhm = val;
} else {
if (val > CONTACT_MAX_MOHM) {
Serial.println("REJECTED: contact range is 0-5000 milliohm (0-5 ohm).");
return;
}
if (cmd == 'F') simFrontMohm = val;
else simBackMohm = val;
}
printInputs();
}
void commitPending() {
if (pendingDigits == 0) {
Serial.println("Enter a number after K, F or B, e.g. K1500");
} else {
setSimValue(pendingCmd, pendingVal);
}
pendingCmd = 0;
}
bool softwareSelfCheck() {
bool ok = (HISTORY_SIZE > 0 && MEAS_COUNT == 7 &&
PROFILE_COUNT == 30 && selectedProfile < PROFILE_COUNT &&
simCoilOhm <= COIL_MAX_OHM && simFrontMohm <= CONTACT_MAX_MOHM &&
simBackMohm <= CONTACT_MAX_MOHM);
Serial.print("Software configuration check: ");
Serial.println(ok ? "PASS" : "FAIL");
Serial.println("Physical instrument diagnostics: NOT AVAILABLE");
Serial.println("Measurement hardware: NOT IMPLEMENTED");
Serial.println("Acceptance limits: UNVERIFIED; no limits are applied");
Serial.println("Persistent storage: NOT IMPLEMENTED (RAM-only demo)");
return ok;
}
void printMeasurements(const TestRecord &r) {
Serial.println("MEASUREMENT RESULTS:");
for (uint8_t i = 0; i < MEAS_COUNT; i++) {
Serial.print(" ");
Serial.print(MEAS[i].name);
Serial.print(" [");
Serial.print(MEAS[i].unit);
Serial.print("]: ");
if (r.measurements[i].available) {
char v[16];
formatMeasurement(i, r.measurements[i].value, v, sizeof(v));
Serial.print(v);
} else {
Serial.print("N/A");
}
Serial.print(" | Source: ");
Serial.print(r.measurements[i].source);
Serial.print(" | Status: ");
Serial.println(r.measurements[i].status);
}
}
void printHistory() {
separator();
Serial.println("TEST HISTORY - CURRENT SESSION ONLY");
Serial.print("Retained records: "); Serial.println(historyCount);
Serial.print("Total tests this session: "); Serial.println(totalTests);
Serial.print("PASS: "); Serial.println(totalPass);
Serial.print("FAIL: "); Serial.println(totalFail);
if (historyCount == 0) {
Serial.println("No test records yet.");
separator();
return;
}
uint8_t first = (historyCount == HISTORY_SIZE) ? nextHistoryIndex : 0;
for (uint8_t n = 0; n < historyCount; n++) {
const TestRecord &r = history[(first + n) % HISTORY_SIZE];
Serial.print("#"); Serial.print(r.testNumber);
Serial.print(" | "); Serial.print(PROFILES[r.profileIndex].type);
Serial.print(" "); Serial.print(PROFILES[r.profileIndex].contacts);
Serial.print(" | "); Serial.print(r.passed ? "PASS" : "FAIL");
Serial.print(" | Pick-up at: ");
if (r.feedbackStartMs >= 0) {
Serial.print(r.feedbackStartMs);
Serial.print(" ms");
} else {
Serial.print("N/A");
}
Serial.print(" | Pick-up to drop-away: ");
Serial.print(r.feedbackHoldMs);
Serial.print(" ms | Session elapsed: ");
Serial.print(r.sessionElapsedMs);
Serial.print(" ms");
Serial.print(" | Pick-up V: ");
if (r.measurements[MEAS_PICKUP].available) {
char v[16];
formatCV(v, sizeof(v), r.measurements[MEAS_PICKUP].value);
Serial.print(v);
} else {
Serial.print("N/A");
}
Serial.print(" | Drop-away V: ");
if (r.measurements[MEAS_DROPAWAY].available) {
char v[16];
formatCV(v, sizeof(v), r.measurements[MEAS_DROPAWAY].value);
Serial.print(v);
} else {
Serial.print("N/A");
}
Serial.print(" | Operate ms: ");
if (r.measurements[MEAS_OPERATE].available) Serial.print(r.measurements[MEAS_OPERATE].value);
else Serial.print("N/A");
Serial.print(" | Release ms: ");
if (r.measurements[MEAS_RELEASE].available) Serial.print(r.measurements[MEAS_RELEASE].value);
else Serial.print("N/A");
Serial.print(" | Reason: ");
Serial.println(r.reason);
}
Serial.println("NOTE: RAM history is lost when simulation restarts.");
separator();
}
void saveRecord(bool passed, long startMs, unsigned long holdMs,
const char *reason) {
TestRecord r;
r.testNumber = nextTestNumber++;
r.sessionElapsedMs = millis() - sessionStartMs;
r.passed = passed;
r.profileIndex = selectedProfile;
r.feedbackStartMs = startMs;
r.feedbackHoldMs = holdMs;
copyText(r.reason, sizeof(r.reason), reason);
clearMeasurements(r.measurements);
if (gotPickup) {
r.measurements[MEAS_PICKUP].available = true;
r.measurements[MEAS_PICKUP].value = pickupCV;
r.measurements[MEAS_PICKUP].source = "SIMULATED_RAMP";
r.measurements[MEAS_PICKUP].status = "SIMULATED";
}
if (gotDropAway) {
r.measurements[MEAS_DROPAWAY].available = true;
r.measurements[MEAS_DROPAWAY].value = dropAwayCV;
r.measurements[MEAS_DROPAWAY].source = "SIMULATED_RAMP";
r.measurements[MEAS_DROPAWAY].status = "SIMULATED";
}
if (gotCoil) {
r.measurements[MEAS_COIL].available = true;
r.measurements[MEAS_COIL].value = measCoilOhm;
r.measurements[MEAS_COIL].source = "SIMULATED_INPUT";
r.measurements[MEAS_COIL].status = "SIMULATED";
}
if (gotContacts) {
r.measurements[MEAS_FRONT].available = true;
r.measurements[MEAS_FRONT].value = measFrontMohm;
r.measurements[MEAS_FRONT].source = "SIMULATED_INPUT";
r.measurements[MEAS_FRONT].status = "SIMULATED";
r.measurements[MEAS_BACK].available = true;
r.measurements[MEAS_BACK].value = measBackMohm;
r.measurements[MEAS_BACK].source = "SIMULATED_INPUT";
r.measurements[MEAS_BACK].status = "SIMULATED";
}
if (gotOperate) {
r.measurements[MEAS_OPERATE].available = true;
r.measurements[MEAS_OPERATE].value = operateMs;
r.measurements[MEAS_OPERATE].source = "SIMULATED_TIMING";
r.measurements[MEAS_OPERATE].status = "SIMULATED";
}
if (gotRelease) {
r.measurements[MEAS_RELEASE].available = true;
r.measurements[MEAS_RELEASE].value = releaseMs;
r.measurements[MEAS_RELEASE].source = "SIMULATED_TIMING";
r.measurements[MEAS_RELEASE].status = "SIMULATED";
}
history[nextHistoryIndex] = r;
nextHistoryIndex = (nextHistoryIndex + 1) % HISTORY_SIZE;
if (historyCount < HISTORY_SIZE) historyCount++;
totalTests++;
if (passed) totalPass++;
else totalFail++;
Serial.print("RAM history record saved. Retained records: ");
Serial.println(historyCount);
}
// Count comma-separated CSV fields while respecting quoted fields and
// escaped double quotes. Empty fields are counted correctly.
size_t countCSVFields(const char *line) {
if (line == nullptr || *line == '\0') return 0;
size_t fields = 1;
bool insideQuotes = false;
for (size_t i = 0; line[i] != '\0'; i++) {
if (line[i] == '"') {
if (insideQuotes && line[i + 1] == '"') {
i++; // Escaped quote inside a quoted field.
} else {
insideQuotes = !insideQuotes;
}
} else if (line[i] == ',' && !insideQuotes) {
fields++;
}
}
return fields;
}
bool appendCSVField(char *row, size_t capacity, size_t &used,
bool &firstField, const char *value, bool quoteField) {
if (!firstField) {
if (used + 1 >= capacity) return false;
row[used++] = ',';
}
firstField = false;
if (quoteField) {
if (used + 1 >= capacity) return false;
row[used++] = '"';
}
if (value != nullptr) {
for (const char *p = value; *p; ++p) {
if (quoteField && *p == '"') {
if (used + 2 >= capacity) return false;
row[used++] = '"';
row[used++] = '"';
} else {
if (used + 1 >= capacity) return false;
row[used++] = *p;
}
}
}
if (quoteField) {
if (used + 1 >= capacity) return false;
row[used++] = '"';
}
row[used] = '\0';
return true;
}
bool appendCSVNumber(char *row, size_t capacity, size_t &used,
bool &firstField, unsigned long value) {
char number[24];
writeUnsigned(number, sizeof(number), value);
return appendCSVField(row, capacity, used, firstField, number, false);
}
bool buildCSVRecord(const TestRecord &r, char *row, size_t capacity) {
size_t used = 0;
bool firstField = true;
row[0] = '\0';
if (!appendCSVField(row, capacity, used, firstField, "RAM_SESSION", false) ||
!appendCSVField(row, capacity, used, firstField, SOFTWARE_VERSION, true) ||
!appendCSVNumber(row, capacity, used, firstField, r.sessionElapsedMs) ||
!appendCSVField(row, capacity, used, firstField, PROFILES[r.profileIndex].type, true) ||
!appendCSVField(row, capacity, used, firstField, PROFILES[r.profileIndex].rating, true) ||
!appendCSVField(row, capacity, used, firstField, PROFILES[r.profileIndex].contacts, true) ||
!appendCSVField(row, capacity, used, firstField, RELAY_CODE, true) ||
!appendCSVNumber(row, capacity, used, firstField, r.testNumber) ||
!appendCSVField(row, capacity, used, firstField, r.passed ? "PASS" : "FAIL", false)) {
return false;
}
// PickupTime_ms is deliberately blank when no pick-up was detected.
char startText[24];
if (r.feedbackStartMs >= 0) {
writeUnsigned(startText, sizeof(startText), (unsigned long)r.feedbackStartMs);
} else {
startText[0] = '\0';
}
if (!appendCSVField(row, capacity, used, firstField, startText, false) ||
!appendCSVNumber(row, capacity, used, firstField, r.feedbackHoldMs) ||
!appendCSVField(row, capacity, used, firstField, r.reason, true)) {
return false;
}
for (uint8_t m = 0; m < MEAS_COUNT; m++) {
// Each measurement contributes value, source and status.
// Value is blank when the measurement is not available.
char valueText[16];
valueText[0] = '\0';
if (r.measurements[m].available) {
formatMeasurement(m, r.measurements[m].value, valueText, sizeof(valueText));
}
if (!appendCSVField(row, capacity, used, firstField, valueText, false) ||
!appendCSVField(row, capacity, used, firstField,
r.measurements[m].source, true) ||
!appendCSVField(row, capacity, used, firstField,
r.measurements[m].status, true)) {
return false;
}
}
return true;
}
const char CSV_HEADER[] =
"StorageScope,SoftwareVersion,SessionElapsed_ms,Relay,RatingAsListed,Contacts,"
"RelayCode,TestNo,Result,PickupTime_ms,PickupToDropAway_ms,Reason,"
"PickupVoltage_V,PickupVoltage_Source,PickupVoltage_Status,"
"DropAwayVoltage_V,DropAwayVoltage_Source,DropAwayVoltage_Status,"
"CoilResistance_ohm,CoilResistance_Source,CoilResistance_Status,"
"FrontContactResistance_ohm,FrontContactResistance_Source,FrontContactResistance_Status,"
"BackContactResistance_ohm,BackContactResistance_Source,BackContactResistance_Status,"
"OperateTime_ms,OperateTime_Source,OperateTime_Status,"
"ReleaseTime_ms,ReleaseTime_Source,ReleaseTime_Status";
void validateCSVFieldCounts() {
separator();
Serial.println("CSV FIELD-COUNT CHECK");
Serial.println("Expected CSV columns per row: 33");
size_t headerFields = countCSVFields(CSV_HEADER);
Serial.print("Header field count: ");
Serial.println(headerFields);
bool allValid = (headerFields == CSV_COLUMN_COUNT);
if (historyCount == 0) {
Serial.println("No records available. Run at least one test first.");
Serial.print("Header check: ");
Serial.println(allValid ? "PASS" : "FAIL");
separator();
return;
}
uint8_t first = (historyCount == HISTORY_SIZE) ? nextHistoryIndex : 0;
for (uint8_t n = 0; n < historyCount; n++) {
const TestRecord &r = history[(first + n) % HISTORY_SIZE];
char row[CSV_ROW_BUFFER_SIZE];
Serial.print("Test #");
Serial.print(r.testNumber);
Serial.print(": ");
if (!buildCSVRecord(r, row, sizeof(row))) {
Serial.println("FAIL - CSV row buffer too small");
allValid = false;
continue;
}
size_t fields = countCSVFields(row);
Serial.print(fields);
Serial.print(" fields - ");
bool rowValid = (fields == CSV_COLUMN_COUNT);
Serial.println(rowValid ? "PASS" : "FAIL");
if (!rowValid) allValid = false;
}
Serial.print("CSV field-count verification: ");
Serial.println(allValid ? "PASS" : "FAIL");
Serial.println("This checks CSV structure, not relay performance.");
separator();
}
void exportCSV() {
Serial.println("CSV_EXPORT_BEGIN");
Serial.println(CSV_HEADER);
uint8_t first = (historyCount == HISTORY_SIZE) ? nextHistoryIndex : 0;
bool exportRowsValid = true;
for (uint8_t n = 0; n < historyCount; n++) {
const TestRecord &r = history[(first + n) % HISTORY_SIZE];
char row[CSV_ROW_BUFFER_SIZE];
if (!buildCSVRecord(r, row, sizeof(row))) {
Serial.print("CSV ERROR: row buffer too small for test #");
Serial.println(r.testNumber);
exportRowsValid = false;
continue;
}
size_t fields = countCSVFields(row);
Serial.println(row);
if (fields != CSV_COLUMN_COUNT) {
Serial.print("CSV WARNING: test #");
Serial.print(r.testNumber);
Serial.print(" contains ");
Serial.print(fields);
Serial.println(" fields; expected 33.");
exportRowsValid = false;
}
}
Serial.println("CSV_EXPORT_END");
Serial.println("CSV note: current-session RAM records only; voltages are SIMULATED.");
Serial.print("CSV header fields: ");
Serial.println(countCSVFields(CSV_HEADER));
Serial.print("CSV row field-count check: ");
Serial.println(exportRowsValid ? "PASS" : "FAIL");
Serial.println("CSV schema target: 33 columns per row.");
}
// Checks the logical consistency of a saved simulated test record.
// It does not validate real relay behavior or official acceptance limits.
bool validateRecord(const TestRecord &r) {
if (r.profileIndex >= PROFILE_COUNT) return false;
const MeasurementResult &pu = r.measurements[MEAS_PICKUP];
const MeasurementResult &da = r.measurements[MEAS_DROPAWAY];
const MeasurementResult &co = r.measurements[MEAS_COIL];
const MeasurementResult &fr = r.measurements[MEAS_FRONT];
const MeasurementResult &ba = r.measurements[MEAS_BACK];
const MeasurementResult &op = r.measurements[MEAS_OPERATE];
const MeasurementResult &rl = r.measurements[MEAS_RELEASE];
// The coil resistance is measured first, so every record must have it.
if (!co.available || co.value < 0 || co.value > COIL_MAX_OHM) return false;
// Contacts are available together, and always within range.
if (fr.available != ba.available) return false;
if (fr.available && (fr.value < 0 || fr.value > CONTACT_MAX_MOHM ||
ba.value < 0 || ba.value > CONTACT_MAX_MOHM)) return false;
if (r.passed) {
return strcmp(r.reason, "All measurements captured") == 0 &&
pu.available && da.available && fr.available && op.available &&
rl.available && r.feedbackStartMs >= 0 &&
da.value <= pu.value && pu.value > 0 && pu.value <= MAX_CV &&
op.value >= 0 && op.value <= (long)TIMING_TIMEOUT_MS &&
rl.value >= 0 && rl.value <= (long)TIMING_TIMEOUT_MS;
}
if (strcmp(r.reason, "No pick-up by 60 V") == 0) {
return !pu.available && !da.available && !fr.available &&
!op.available && !rl.available && r.feedbackStartMs == -1;
}
if (strcmp(r.reason, "No drop-away before 0 V") == 0) {
return pu.available && !da.available && !fr.available &&
!op.available && !rl.available && r.feedbackStartMs >= 0;
}
if (strcmp(r.reason, "No operate within 3 s") == 0) {
return pu.available && da.available && fr.available &&
!op.available && !rl.available && r.feedbackStartMs >= 0;
}
if (strcmp(r.reason, "Feedback not held at coil off") == 0 ||
strcmp(r.reason, "No release within 3 s") == 0) {
return pu.available && da.available && fr.available &&
op.available && !rl.available && r.feedbackStartMs >= 0;
}
return false;
}
void validateHistory() {
separator();
Serial.println("RECORD INTEGRITY CHECK");
Serial.println("Simulation records only; not an RDSO test.");
unsigned int validCount = 0;
unsigned int invalidCount = 0;
if (historyCount == 0) {
Serial.println("No records to validate. Run a test first.");
separator();
return;
}
uint8_t first = (historyCount == HISTORY_SIZE) ? nextHistoryIndex : 0;
for (uint8_t n = 0; n < historyCount; n++) {
const TestRecord &r = history[(first + n) % HISTORY_SIZE];
bool valid = validateRecord(r);
Serial.print("Test #");
Serial.print(r.testNumber);
Serial.print(": ");
Serial.println(valid ? "VALID" : "CHECK REQUIRED");
if (valid) {
validCount++;
} else {
invalidCount++;
Serial.print(" Result: ");
Serial.println(r.passed ? "PASS" : "FAIL");
Serial.print(" Feedback start (ms): ");
if (r.feedbackStartMs >= 0) Serial.println(r.feedbackStartMs);
else Serial.println("N/A");
Serial.print(" Hold (ms): ");
Serial.println(r.feedbackHoldMs);
Serial.print(" Reason: ");
Serial.println(r.reason);
}
}
Serial.println("------------------------------");
Serial.print("Records checked: "); Serial.println(historyCount);
Serial.print("Valid records: "); Serial.println(validCount);
Serial.print("Records needing review: "); Serial.println(invalidCount);
if (invalidCount == 0) {
Serial.println("Integrity check: PASS");
} else {
Serial.println("Integrity check: REVIEW REQUIRED");
}
Serial.println("This checks stored field consistency only.");
Serial.println("It does not prove physical relay performance.");
separator();
}
void finishTest(bool passed, const char *reason, long feedbackStart,
unsigned long holdMs) {
if (!testInProgress) return;
testInProgress = false;
rampPhase = PHASE_IDLE;
digitalWrite(RELAY_LED_PIN, LOW);
setState(STATE_EVALUATING);
setState(STATE_RECORDING);
saveRecord(passed, feedbackStart, holdMs, reason);
setState(passed ? STATE_COMPLETE_PASS : STATE_COMPLETE_FAIL);
separator();
Serial.print("TEST "); Serial.print(nextTestNumber - 1);
Serial.print(" ("); Serial.print(PROFILES[selectedProfile].type);
Serial.print(" "); Serial.print(PROFILES[selectedProfile].contacts);
Serial.print(") RESULT: "); Serial.println(passed ? "PASS" : "FAIL");
Serial.print("Reason: "); Serial.println(reason);
Serial.print("Time to pick-up (ms): ");
if (feedbackStart >= 0) Serial.println(feedbackStart);
else Serial.println("N/A");
Serial.print("Pick-up to drop-away interval (ms): "); Serial.println(holdMs);
Serial.println("This verdict applies only to the simulated ramp scenario.");
Serial.println("It is NOT a relay acceptance result.");
printMeasurements(history[(nextHistoryIndex + HISTORY_SIZE - 1) % HISTORY_SIZE]);
printHistory();
setState(STATE_READY);
}
// Records the exact microsecond of every raw (un-debounced) feedback edge.
// On real hardware this job is done by a timer input-capture pin (TIM2),
// not by polling. Here it keeps the timing resolution well below 1 ms.
void trackRawFeedbackEdges() {
int raw = digitalRead(FEEDBACK_PIN);
if (raw != rawTrackPrev) {
rawTrackPrev = raw;
if (raw == LOW) lastRawLowUs = micros();
else lastRawHighUs = micros();
}
}
// Convert a microsecond interval to whole milliseconds (rounded, 1 ms resolution).
long elapsedMsFromUs(unsigned long edgeUs, unsigned long refUs) {
long delta = (long)(edgeUs - refUs);
if (delta < 0) delta = 0;
return (long)((unsigned long)(delta + 500L) / 1000UL);
}
void beginTest(unsigned long now) {
testInProgress = true;
testStartMs = now;
feedbackStartAt = 0;
feedbackHoldMs = 0;
feedbackTimingStarted = false;
rampPhase = PHASE_UP;
gotPickup = false;
gotDropAway = false;
gotOperate = false;
gotRelease = false;
gotContacts = false;
measCoilOhm = simCoilOhm; // coil resistance is measured first
gotCoil = true;
pickupCV = 0;
dropAwayCV = 0;
operateMs = 0;
releaseMs = 0;
pickupAtMs = 0;
pickupToDropMs = 0;
downStartMs = 0;
phaseStartMs = 0;
lastPrintedCV = -1;
digitalWrite(RELAY_LED_PIN, HIGH);
setState(STATE_RUNNING);
Serial.println("SIMULATION: coil ramp UP started (software-generated voltage).");
Serial.print("Coil resistance (simulated input): ");
Serial.print(measCoilOhm);
Serial.println(" ohm");
Serial.println("Hold BLUE for pick-up, release for drop-away; then follow COIL ON / COIL OFF.");
}
void printRampProgress(const char *label, long cv) {
if (lastPrintedCV < 0 || cv >= lastPrintedCV + PRINT_STEP_CV ||
cv <= lastPrintedCV - PRINT_STEP_CV) {
char v[16];
formatCV(v, sizeof(v), cv);
Serial.print(label);
Serial.print(v);
Serial.println(" V");
lastPrintedCV = cv;
}
}
void updateTest(unsigned long now) {
if (!testInProgress) return;
char v[16];
// ---------------- Part 1: coil voltage ramp up ----------------
if (rampPhase == PHASE_UP) {
long cv = (long)((now - testStartMs) * (unsigned long)RAMP_CV_PER_S / 1000UL);
if (cv > MAX_CV) cv = MAX_CV;
printRampProgress("Ramp up: ", cv);
if (feedbackStable == LOW) {
gotPickup = true;
pickupCV = cv;
pickupAtMs = now - testStartMs;
downStartMs = now;
rampPhase = PHASE_DOWN;
lastPrintedCV = -1;
formatCV(v, sizeof(v), pickupCV);
Serial.print("PICK-UP captured at ");
Serial.print(v);
Serial.println(" V (simulated). Ramp DOWN started.");
return;
}
if (cv >= MAX_CV) {
finishTest(false, "No pick-up by 60 V", -1, 0);
}
return;
}
// ---------------- Part 1: coil voltage ramp down ----------------
if (rampPhase == PHASE_DOWN) {
long dec = (long)((now - downStartMs) * (unsigned long)RAMP_CV_PER_S / 1000UL);
long cv = pickupCV - dec;
if (cv < 0) cv = 0;
printRampProgress("Ramp down: ", cv);
if (feedbackStable == HIGH) {
gotDropAway = true;
dropAwayCV = cv;
pickupToDropMs = now - (testStartMs + pickupAtMs);
formatCV(v, sizeof(v), dropAwayCV);
Serial.print("DROP-AWAY captured at ");
Serial.print(v);
Serial.println(" V (simulated).");
measFrontMohm = simFrontMohm; // contacts measured after drop-away
measBackMohm = simBackMohm;
gotContacts = true;
formatMilli(v, sizeof(v), measFrontMohm);
Serial.print("Contact resistance (simulated input): front ");
Serial.print(v);
formatMilli(v, sizeof(v), measBackMohm);
Serial.print(" ohm, back ");
Serial.print(v);
Serial.println(" ohm");
if (measFrontMohm > DRY_CIRCUIT_MAX_MOHM || measBackMohm > DRY_CIRCUIT_MAX_MOHM) {
Serial.println("Contact above 200 mohm: 20 mV dry-circuit limit not applied (Section 31).");
} else {
Serial.println("Contacts <= 200 mohm: 100 mA x R <= 20 mV (simulated).");
}
digitalWrite(RELAY_LED_PIN, LOW); // coil OFF before timing part
rampPhase = PHASE_SETTLE;
phaseStartMs = now;
Serial.println("Timing part: coil OFF. Keep BLUE button released...");
return;
}
if (cv <= 0) {
finishTest(false, "No drop-away before 0 V", (long)pickupAtMs, pickupToDropMs);
}
return;
}
// ---------------- Part 2: settle, then operate timing ----------------
if (rampPhase == PHASE_SETTLE) {
if (now - phaseStartMs >= SETTLE_MS && feedbackStable == HIGH) {
digitalWrite(RELAY_LED_PIN, HIGH);
coilOnUs = micros(); // reference for operate time
rampPhase = PHASE_OPERATE;
phaseStartMs = now;
Serial.println("COIL ON. Press and HOLD the BLUE button now (operate time).");
}
return;
}
if (rampPhase == PHASE_OPERATE) {
if (feedbackStable == LOW) {
operateMs = elapsedMsFromUs(lastRawLowUs, coilOnUs);
gotOperate = true;
Serial.print("OPERATE time captured: ");
Serial.print(operateMs);
Serial.println(" ms (simulated, 1 ms resolution).");
rampPhase = PHASE_HOLD;
phaseStartMs = now;
return;
}
if (now - phaseStartMs >= TIMING_TIMEOUT_MS) {
finishTest(false, "No operate within 3 s", (long)pickupAtMs, pickupToDropMs);
}
return;
}
if (rampPhase == PHASE_HOLD) {
if (now - phaseStartMs >= HOLD_MS) {
if (feedbackStable != LOW) {
finishTest(false, "Feedback not held at coil off", (long)pickupAtMs,
pickupToDropMs);
return;
}
digitalWrite(RELAY_LED_PIN, LOW);
coilOffUs = micros(); // reference for release time
rampPhase = PHASE_RELEASE;
phaseStartMs = now;
Serial.println("COIL OFF. RELEASE the BLUE button now (release time).");
}
return;
}
if (rampPhase == PHASE_RELEASE) {
if (feedbackStable == HIGH) {
releaseMs = elapsedMsFromUs(lastRawHighUs, coilOffUs);
gotRelease = true;
Serial.print("RELEASE time captured: ");
Serial.print(releaseMs);
Serial.println(" ms (simulated, 1 ms resolution).");
finishTest(true, "All measurements captured", (long)pickupAtMs,
pickupToDropMs);
return;
}
if (now - phaseStartMs >= TIMING_TIMEOUT_MS) {
finishTest(false, "No release within 3 s", (long)pickupAtMs, pickupToDropMs);
}
}
}
void setup() {
pinMode(START_PIN, INPUT_PULLUP);
pinMode(FEEDBACK_PIN, INPUT_PULLUP);
pinMode(RELAY_LED_PIN, OUTPUT);
digitalWrite(RELAY_LED_PIN, LOW);
Serial.begin(115200);
unsigned long waitAt = millis();
while (!Serial && millis() - waitAt < 2000UL) {}
delay(200);
sessionStartMs = millis();
unsigned long now = millis();
startRawLast = startStable = digitalRead(START_PIN);
startChangedAt = now;
feedbackRawLast = feedbackStable = digitalRead(FEEDBACK_PIN);
rawTrackPrev = feedbackStable;
feedbackChangedAt = now;
separator();
Serial.println("PORTABLE RAILWAY RELAY TESTER");
Serial.println("EXERCISE 21 - SIMULATED RESISTANCE INPUTS");
Serial.print("Software version: ");
Serial.println(SOFTWARE_VERSION);
Serial.println("SIMULATION ONLY - NOT AN RDSO COMPLIANCE TEST");
Serial.println("History: RAM only; records are lost on restart.");
separator();
setState(STATE_SELF_CHECK);
if (softwareSelfCheck()) {
printProfile();
printInputs();
setState(STATE_READY);
} else {
setState(STATE_SOFTWARE_FAULT);
Serial.println("Start disabled.");
}
Serial.println("Commands: H = history, E = export CSV, V = validate records,");
Serial.println(" C = CSV field-count check, L = list profiles,");
Serial.println(" N = next profile, P = previous profile,");
Serial.println(" R = show resistance inputs, K/F/B<number> = set them");
Serial.println("Press GREEN START to run a simulated ramp + timing test.");
}
void loop() {
while (Serial.available() > 0) {
char c = (char)Serial.read();
lastCharMs = millis();
if (c >= 'a' && c <= 'z') c = (char)(c - 32); // accept lower case
if (pendingCmd != 0) {
if (c >= '0' && c <= '9') {
if (pendingVal < 100000L) pendingVal = pendingVal * 10 + (c - '0');
pendingDigits++;
continue;
}
commitPending();
if (c == '\n' || c == '\r' || c == ' ') continue;
}
if (c == 'K' || c == 'F' || c == 'B') {
pendingCmd = c;
pendingVal = 0;
pendingDigits = 0;
} else if (c == 'H') {
printHistory();
} else if (c == 'E') {
exportCSV();
} else if (c == 'V') {
validateHistory();
} else if (c == 'C') {
validateCSVFieldCounts();
} else if (c == 'L') {
listProfiles();
} else if (c == 'N') {
changeProfile(+1);
} else if (c == 'P') {
changeProfile(-1);
} else if (c == 'R') {
printInputs();
}
}
// A number typed without a line ending is accepted after a short pause.
if (pendingCmd != 0 && millis() - lastCharMs >= 300) commitPending();
unsigned long now = millis();
setDebounced(START_PIN, startRawLast, startStable, startChangedAt, now);
setDebounced(FEEDBACK_PIN, feedbackRawLast, feedbackStable,
feedbackChangedAt, now);
trackRawFeedbackEdges();
static int previousStart = HIGH;
if (testerState == STATE_READY &&
previousStart == HIGH && startStable == LOW) {
beginTest(now);
}
previousStart = startStable;
updateTest(now);
}
Loading
st-nucleo-c031c6
st-nucleo-c031c6