Community project

NiMH Battery Health Tester

ESP32
Photo of NiMH Battery Health Tester
Generated with AI

Testing User

Last updated September 15, 2026

This project builds a dual-channel battery health tester for NiMH cells using two ESP32 controllers to independently monitor and stress-test 7.2 V battery modules. The system performs controlled charge and discharge cycles while measuring voltage, current, and temperature in real time, allowing makers to assess battery capacity degradation and internal resistance over multiple test runs.

The guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for mounting the high-current charging modules, connecting the dual INA237 power monitors and DS18B20 temperature sensors, and configuring the RS485 communication network between the main controller and two data acquisition nodes. Firmware is included to automate the test sequence, log results, and enforce safety interlocks throughout operation.

Wiring diagram

Wiring diagram for NiMH Battery Health Tester

Gather all the parts

QtyComponent
1

XY-017 UART-to-RS485 converter, Main Controller

XY-017; configure for 3.3 V TTL

A selectable 3.3 V or 5 V half-duplex RS485 module that links the Main Controller to the two DAQ units.

1

ESP32 DevKit v1 DAQ controller 1

RS485 address 01

The first independent measurement controller that samples one battery module and runs its local current-control safety loop.

1

ESP32 DevKit v1 DAQ controller 2

RS485 address 02

The second independent measurement controller that samples one battery module and runs its local current-control safety loop.

1

Adafruit INA237 Power Monitor

15 mΩ, ±10 A

STEMMA QT I2C DC current, voltage, and power monitor breakout using the INA237, with up to 85 V bus measurement, high-side or low-side sensing, onboard 15 milliohm shunt, and 3 V or 5 V logic compatibility.

1

Adafruit INA237 Power Monitor

15 mΩ, ±10 A

STEMMA QT I2C DC current, voltage, and power monitor breakout using the INA237, with up to 85 V bus measurement, high-side or low-side sensing, onboard 15 milliohm shunt, and 3 V or 5 V logic compatibility.

1

DS18B20

module 1 probe

Digital temperature sensor using OneWire protocol

1

DS18B20

module 2 probe

Digital temperature sensor using OneWire protocol

1

10 A CC/CV buck charger module, channel 1

12 V input, at least 10 A CC/CV; manually calibrated

A configurable step-down charger that takes the shared 12 V supply and supplies controlled current to the first 7.2 V NiMH module.

1

10 A CC/CV buck charger module, channel 2

12 V input, at least 10 A CC/CV; manually calibrated

A configurable step-down charger that takes the shared 12 V supply and supplies controlled current to the second 7.2 V NiMH module.

1

230 VAC to 12 V 20 A enclosed isolated SMPS

12 V, 20 A minimum; 230 VAC 50 Hz input

A mains-powered enclosed supply that safely converts Sri Lankan 230 V AC power into one 12 V DC distribution bus.

1

7.2 V NiMH HEV battery module 1

nominal 7.2 V

The first six-cell nominal NiMH module whose short charge and discharge curves are being measured.

1

7.2 V NiMH HEV battery module 2

nominal 7.2 V

The second six-cell nominal NiMH module whose short charge and discharge curves are being measured.

2

4.7k Pull Up Resistor

4.7 kΩ

4.7kΩ pull-up resistor used in 1-Wire (OneWire) communication buses. Essential passive component that pulls the single data line to 3.3V or 5V when idle. Works with 1-Wire protocol devices (DS18B20, DS18S20, iButton, etc.) to ensure proper bus communication at standard 16.3 kbit/s data rates. Supports both 3.3V and 5V systems.

1

Dual-channel hardware break-before-make safety interlock

two-channel, NC E-stop

A hardware interlock with emergency-stop input that ensures a module's charge and discharge stages cannot be enabled together.

1

XY-017 UART-to-RS485 converter, DAQ 1

XY-017; configure for 3.3 V TTL

A selectable 3.3 V or 5 V half-duplex RS485 module that links DAQ unit 1 to the shared communications cable.

1

XY-017 UART-to-RS485 converter, DAQ 2

XY-017; configure for 3.3 V TTL

A selectable 3.3 V or 5 V half-duplex RS485 module that links DAQ unit 2 to the shared communications cable.

1

MCP4725 DAC Module

channel 1, 0–3.3 V

12-bit single-channel I2C DAC with onboard EEPROM for storing settings. Outputs 0V to VCC analog voltage (0–3.3V when powered at 3.3V). I2C address 0x60 (or 0x61 via ADDR pin). Compatible with 3.3V and 5V systems. Used here to generate a 0–3.3V analog control signal for LED driver dimming; output must be scaled to 0–10V via op-amp before connecting to MeanWell HLG-320H-12AB DIM+ terminal.

1

MCP4725 DAC Module, channel 2

channel 2, 0–3.3 V; A0 tied high, I2C address 0x61

A small digital-to-analogue board that creates the calibrated current command for the second battery channel.

1

5 V 3 A DC-DC buck converter, DAQ 1 branch

5 V, 3 A

A small converter that makes a clean 5 V supply for DAQ unit 1 from the shared 12 V bus.

1

5 V 3 A DC-DC buck converter, DAQ 2 branch

5 V, 3 A

A small converter that makes a clean 5 V supply for DAQ unit 2 from the shared 12 V bus.

1

IRFP250N MOSFET, discharge channel 1

IRFP250N TO-247; insulated forced-air heat sink

A heat-sink-mounted MOSFET that dissipates battery power under analogue feedback control for the first module.

1

IRFP250N MOSFET, discharge channel 2

IRFP250N TO-247; insulated forced-air heat sink

A heat-sink-mounted MOSFET that dissipates battery power under analogue feedback control for the second module.

1

0.10 ohm 10 W current-sense resistor, discharge channel 1

0.10 ohm, 10 W wirewound or metal-clad

A low-value power resistor that lets the feedback circuit measure and regulate channel 1 discharge current.

1

0.10 ohm 10 W current-sense resistor, discharge channel 2

0.10 ohm, 10 W wirewound or metal-clad

A low-value power resistor that lets the feedback circuit measure and regulate channel 2 discharge current.

1

LM358 analogue current-control circuit, discharge channel 1

LM358, 10-turn trim, 100 ohm gate resistor, 100 kΩ gate pull-down

An op-amp feedback circuit that compares the DAC setpoint with shunt voltage and regulates the first MOSFET's battery current.

1

LM358 analogue current-control circuit, discharge channel 2

LM358, 10-turn trim, 100 ohm gate resistor, 100 kΩ gate pull-down

An op-amp feedback circuit that compares the DAC setpoint with shunt voltage and regulates the second MOSFET's battery current.

Assemble it in 8 steps

1. Mount the high-current equipment safely

Place the two 150 W electronic loads on a non-flammable bench with clear airflow around their heat sinks and fans. Mount the two charging stages, the 15 V laboratory supply, fuses, and the normally closed mushroom emergency-stop switch in an insulated enclosure or guarded test fixture. Keep the ESP32 boards and RS485 wiring physically away from the high-current battery cables.

  • Use short, heavy battery leads sized for at least 10 A continuous current.
  • Label the two channels CH1 and CH2 before connecting anything.
  • The electronic loads can become hot enough to burn skin during repeated tests; do not touch their heat sinks until they cool.
  • Do not use an unguarded breadboard for 5–6.5 A battery wiring; loose wires can overheat or start a fire.

2. Make the protected battery connections

For each module, fit a 10 A DC-rated fuse in the positive lead close to the battery terminal. Connect module 1 positive to current_monitor_1 VIN+ and the channel 1 protected positive bus; connect charge_stage_1 OUT+ through the monitor path and load_stage_1 IN+ to that same protected bus. Connect charge_stage_1 OUT- and load_stage_1 IN- to battery_module_1 NEG. Repeat the same arrangement for module 2 with current_monitor_2, charge_stage_2, load_stage_2, and battery_module_2. The positive lead carries the test current; the negative lead completes its return path.

  • Use the same cable length and connector type on both channels for more repeatable data.
  • Before connecting a module, check its open-circuit voltage with a meter and ensure the correct positive and negative terminals are identified.
  • Reversing a NiMH module can damage the charger, electronic load, current monitor, or wiring.
  • The INA237 monitor’s 15 mΩ shunt is only suitable when the actual board and shunt are verified for the full pulse current; do not exceed its stated current and power rating.

3. Wire the 15 V charging supply

With the laboratory supply switched off, connect supply_15v V+ to charge_stage_1 DC_IN+ and charge_stage_2 DC_IN+. Connect supply_15v V- to charge_stage_1 DC_IN- and charge_stage_2 DC_IN-. Set the supply current limit conservatively before the first powered check, then raise it only after verifying that both charger stages are off at zero command.

  • A 15 V, 25 A isolated supply gives room for two 6.5 A charge pulses plus conversion losses.
  • Use a separately fused branch for each charging stage.
  • Do not connect the 15 V supply directly to a 7.2 V module; the regulated charging stage must be between them.
  • Wrong supply polarity can damage both charging stages immediately.

4. Attach the temperature probes

Tape temp_sensor_1 firmly against the middle of module 1 using electrically insulating tape, then cover it with a small foam pad so it follows cell temperature instead of room airflow. Do the same for temp_sensor_2 on module 2. Connect each DS18B20 VCC to 3V3 (power), GND to GND (ground), and DATA to its local DAQ temperature input (signal). Fit one 4.7 kΩ resistor from each DATA wire to 3V3 so the temperature signal can return to its idle state.

  • Keep each temperature sensor’s wires away from the load heat sink and high-current cables.
  • Confirm that each DAQ reports a believable room-temperature value before a battery is connected.
  • A loose probe can read cool air while the module overheats, defeating the temperature stop protection.

5. Wire each DAQ’s measurement and command side

For channel 1, connect current_monitor_1 VCC to 3V3 (power), GND to daq1_esp32 GND (ground), SDA to daq1_esp32 SDA (data), and SCL to daq1_esp32 SCL (clock). Connect dac_1 VCC to 3V3 (power), GND to daq1_esp32 GND (ground), SDA and SCL to the same local I2C wires, and DAC OUT to command_selector_1 IN (current command). Wire command_selector_1 CHARGE_CMD to charge_stage_1 CURRENT_CMD and LOAD_CMD to load_stage_1 CURRENT_CMD. Repeat these connections for channel 2 with the parts ending in _2. The DAC voltage is only the desired-current command; the charger or load’s own current-feedback circuit drives its MOSFET power stage and measured current toward that request.

  • Calibrate DAC voltage against actual current with a trusted meter before testing batteries.
  • Use the two different MCP4725 address links so each local DAQ can identify its DAC.
  • Never feed battery voltage into an ESP32 GPIO, DAC pin, or I2C pin; they are low-voltage control wires only.
  • Do not assume two different charger/load modules share the same command-voltage-to-current scaling; calibrate every channel.

6. Install the direction interlock and emergency stop

Connect daq1_esp32 CHARGE_EN to safety_interlock CH1_CHARGE_EN (charge request) and daq1_esp32 DISCHARGE_EN to safety_interlock CH1_LOAD_EN (discharge request). Connect the corresponding channel 2 signals to CH2 inputs. Connect the interlock outputs to each stage’s INHIBIT input and its select output to the channel command selector. Wire the mushroom switch as the normally closed E_STOP_NC loop. The interlock must force both charge and discharge enables off when the stop button opens or when opposite paths are requested.

  • Test the emergency-stop switch with no battery attached: it must disable every stage immediately.
  • The firmware must command zero current, wait for measured current to settle, then switch direction; the hardware interlock is the second layer of protection.
  • Do not bypass the hardware interlock: charging and discharging a module through incompatible paths at the same time can destroy equipment and overheat the module.

7. Build the shared RS485 communication cable

On the main controller, connect rs485_bus VCC to 3V3 (power), GND to GND (ground), RO to GPIO16 (receive data), DI to GPIO17 (send data), DE to GPIO27 (send-enable signal), and RE to GPIO26 (receive-enable signal). Daisy-chain the A wire from rs485_bus to rs485_daq1 A to rs485_daq2 A, and similarly daisy-chain B to B. Use one twisted pair for A and B, add a 120 Ω resistor across A and B only at the two physical ends of the cable, and use a common signal reference or isolated RS485 transceivers as required by the fixture grounding arrangement.

  • Label the nodes Master, DAQ 01, and DAQ 02.
  • Keep the RS485 cable separate from high-current leads to reduce electrical noise.
  • A and B labels vary between RS485 modules; if the bus does not communicate, compare each module’s data sheet rather than swapping wires while equipment is powered.
  • Do not place a 120 Ω termination resistor at every RS485 node; only the cable ends receive termination.

8. Check the system before a live test

Power the ESP32 boards through USB first, with the 15 V supply off and no modules connected. Confirm that all stages remain inhibited, both temperature readings are sensible, and each DAQ answers at address 01 or 02. Then connect the modules, release the emergency stop, and perform a low-current calibration check before allowing the programmed 6.5 A charge pulse, 5 A discharge pulse, and 2.1667 A fifteen-minute charge hold.

  • Start with the stages limited to a small current and compare each INA237 reading with an external meter.
  • The main controller records timestamp, DAQ ID, voltage, current, temperature, current target, state, and status every roughly 500 ms.
  • Stop immediately if a module becomes unusually warm, swells, leaks, produces odor, reports a sensor fault, or exceeds its approved test voltage.
  • The voltage cutoffs in firmware are conservative starting limits; confirm them against the exact module manufacturer’s allowable charge and discharge limits before unattended operation.

Review all connections

1. Connections between "supply_15v" and "ESP32"

Functionsupply_15vESP32
powerAC_L230 V AC live through a correctly rated fuse, switch, and IEC inletEXT
powerAC_N230 V AC neutral from IEC inletEXT
groundPEprotective earth bonded to the metal enclosureEXT
powerV+10 A CC/CV buck charger module, channel 1 IN+EXT
groundV-10 A CC/CV buck charger module, channel 1 IN-EXT

2. Connections between "logic_buck_daq1" and "ESP32"

Functionlogic_buck_daq1ESP32
powerIN+VIN
groundIN-GND
powerOUT+ESP32 DevKit v1 DAQ controller 1 5VEXT
groundOUT-ESP32 DevKit v1 DAQ controller 1 GNDEXT

3. Connections between "logic_buck_daq2" and "ESP32"

Functionlogic_buck_daq2ESP32
powerIN+VIN
groundIN-GND
powerOUT+ESP32 DevKit v1 DAQ controller 2 5VEXT
groundOUT-ESP32 DevKit v1 DAQ controller 2 GNDEXT

4. Connections between "charge_stage_1" and "ESP32"

Functioncharge_stage_1ESP32
powerIN+VIN
groundIN-GND
powerOUT+Adafruit INA237 Power Monitor VIN-EXT
groundOUT-7.2 V NiMH HEV battery module 1 NEGEXT
digitalENABLEDual-channel hardware break-before-make safety interlock CH1_CHARGE_OUTEXT

5. Connections between "charge_stage_2" and "ESP32"

Functioncharge_stage_2ESP32
powerIN+VIN
groundIN-GND
powerOUT+Adafruit INA237 Power Monitor VIN-EXT
groundOUT-7.2 V NiMH HEV battery module 2 NEGEXT
digitalENABLEDual-channel hardware break-before-make safety interlock CH2_CHARGE_OUTEXT

6. Connections between "battery_module_1" and "ESP32"

Functionbattery_module_1ESP32
powerPOSAdafruit INA237 Power Monitor VIN+EXT

7. Connections between "battery_module_2" and "ESP32"

Functionbattery_module_2ESP32
powerPOSAdafruit INA237 Power Monitor VIN+EXT

8. Connections between "load_mosfet_1" and "ESP32"

Functionload_mosfet_1ESP32
powerDRAINAdafruit INA237 Power Monitor VIN-EXT
analogSOURCE0.10 ohm 10 W current-sense resistor, discharge channel 1 SENSE_PLUSEXT
analogGATELM358 analogue current-control circuit, discharge channel 1 GATE_OUTEXT

9. Connections between "load_shunt_1" and "ESP32"

Functionload_shunt_1ESP32
groundSENSE_MINUS7.2 V NiMH HEV battery module 1 NEGEXT

10. Connections between "load_mosfet_2" and "ESP32"

Functionload_mosfet_2ESP32
powerDRAINAdafruit INA237 Power Monitor VIN-EXT
analogSOURCE0.10 ohm 10 W current-sense resistor, discharge channel 2 SENSE_PLUSEXT
analogGATELM358 analogue current-control circuit, discharge channel 2 GATE_OUTEXT

11. Connections between "load_shunt_2" and "ESP32"

Functionload_shunt_2ESP32
groundSENSE_MINUS7.2 V NiMH HEV battery module 2 NEGEXT

12. Connections between "load_control_1" and "ESP32"

Functionload_control_1ESP32
powerVCCVIN
groundGND7.2 V NiMH HEV battery module 1 NEGEXT
analogSETPOINTMCP4725 DAC Module OUTEXT
analogSENSE0.10 ohm 10 W current-sense resistor, discharge channel 1 SENSE_PLUSEXT
digitalENABLEDual-channel hardware break-before-make safety interlock CH1_LOAD_OUTEXT

13. Connections between "load_control_2" and "ESP32"

Functionload_control_2ESP32
powerVCCVIN
groundGND7.2 V NiMH HEV battery module 2 NEGEXT
analogSENSE0.10 ohm 10 W current-sense resistor, discharge channel 2 SENSE_PLUSEXT
digitalENABLEDual-channel hardware break-before-make safety interlock CH2_LOAD_OUTEXT

14. Connections between "current_monitor_1" and "ESP32"

Functioncurrent_monitor_1ESP32
powerVCC3V3
groundGNDESP32 DevKit v1 DAQ controller 1 GNDEXT
i2cSDAESP32 DevKit v1 DAQ controller 1 SDAEXT
i2cSCLESP32 DevKit v1 DAQ controller 1 SCLEXT

15. Connections between "current_monitor_2" and "ESP32"

Functioncurrent_monitor_2ESP32
powerVCC3V3
groundGNDESP32 DevKit v1 DAQ controller 2 GNDEXT
i2cSDAESP32 DevKit v1 DAQ controller 2 SDAEXT
i2cSCLESP32 DevKit v1 DAQ controller 2 SCLEXT

16. Connections between "temp_sensor_1" and "ESP32"

Functiontemp_sensor_1ESP32
powerVCC3V3
groundGNDESP32 DevKit v1 DAQ controller 1 GNDEXT
dataDATAESP32 DevKit v1 DAQ controller 1 TEMPEXT

17. Connections between "temp_sensor_2" and "ESP32"

Functiontemp_sensor_2ESP32
powerVCC3V3
groundGNDESP32 DevKit v1 DAQ controller 2 GNDEXT
dataDATAESP32 DevKit v1 DAQ controller 2 TEMPEXT

18. Connections between "onewire_pullups" and "ESP32"

Functiononewire_pullupsESP32
powerVCC3V3
groundGNDGND
dataDATA_LINEESP32 DevKit v1 DAQ controller 1 TEMPEXT

19. Connections between "dac_1" and "ESP32"

Functiondac_1ESP32
powerVCC3V3
groundGNDESP32 DevKit v1 DAQ controller 1 GNDEXT
i2cSDAESP32 DevKit v1 DAQ controller 1 SDAEXT
i2cSCLESP32 DevKit v1 DAQ controller 1 SCLEXT

20. Connections between "dac_2" and "ESP32"

Functiondac_2ESP32
powerVCC3V3
groundGNDESP32 DevKit v1 DAQ controller 2 GNDEXT
i2cSDAESP32 DevKit v1 DAQ controller 2 SDAEXT
i2cSCLESP32 DevKit v1 DAQ controller 2 SCLEXT
analogVOUTLM358 analogue current-control circuit, discharge channel 2 SETPOINTEXT
powerA03V3

21. Connections between "rs485_bus" and "ESP32"

Functionrs485_busESP32
powerVCC3V3
groundGNDGND
uartTXGPIO 16
uartRXGPIO 17
digitalDE_REGPIO 27
dataAXY-017 UART-to-RS485 converter, DAQ 1 AEXT
dataBXY-017 UART-to-RS485 converter, DAQ 1 BEXT

22. Connections between "rs485_daq1" and "ESP32"

Functionrs485_daq1ESP32
powerVCC3V3
groundGNDESP32 DevKit v1 DAQ controller 1 GNDEXT
uartTXESP32 DevKit v1 DAQ controller 1 RS485_RXEXT
uartRXESP32 DevKit v1 DAQ controller 1 RS485_TXEXT
digitalDE_REESP32 DevKit v1 DAQ controller 1 DISCHARGE_ENEXT
dataAXY-017 UART-to-RS485 converter, DAQ 2 AEXT
dataBXY-017 UART-to-RS485 converter, DAQ 2 BEXT

23. Connections between "rs485_daq2" and "ESP32"

Functionrs485_daq2ESP32
powerVCC3V3
groundGNDESP32 DevKit v1 DAQ controller 2 GNDEXT
uartTXESP32 DevKit v1 DAQ controller 2 RS485_RXEXT
uartRXESP32 DevKit v1 DAQ controller 2 RS485_TXEXT
digitalDE_REESP32 DevKit v1 DAQ controller 2 DISCHARGE_ENEXT

24. Connections between "safety_interlock" and "ESP32"

Functionsafety_interlockESP32
digitalCH1_CHARGE_ENESP32 DevKit v1 DAQ controller 1 CHARGE_ENEXT
digitalCH1_LOAD_ENESP32 DevKit v1 DAQ controller 1 DISCHARGE_ENEXT
digitalCH2_CHARGE_ENESP32 DevKit v1 DAQ controller 2 CHARGE_ENEXT
digitalCH2_LOAD_ENESP32 DevKit v1 DAQ controller 2 DISCHARGE_ENEXT
digitalE_STOP_NCnormally closed mushroom emergency-stop switchEXT

Deploy the firmware

#include <Arduino.h>

// Main Controller (MC) firmware for one ESP32 DevKit v1.
// Flash this file to the MC. DAQ 01 and DAQ 02 use the two child sketches
// supplied separately in the project assembly record.
// RS485 protocol: MC polls @R,<address>,<CRC16> every 500 ms.

enum TestState : uint8_t { IDLE, CHARGE_PULSE, REST_AFTER_CHARGE, DISCHARGE_PULSE, REST_AFTER_DISCHARGE, CHARGE_HOLD, COMPLETE, FAULT };
enum Mode : uint8_t { MODE_OFF, MODE_CHARGE, MODE_DISCHARGE };

struct Measurement {
  uint8_t address;
  uint32_t sequence;
  float voltageV;
  float currentA;
  float temperatureC;
  uint16_t status;
  uint32_t receivedAt;
  bool valid;
};


// Forward declarations
uint16_t crc16(const uint8_t *data, size_t length);
void rs485Receive();
void rs485Send(const char *message);
Mode commandedMode();
float commandedCurrent();
void commandOne(uint8_t address, Mode mode, float currentA);
void commandAll(Mode mode, float currentA);
void stopSafely(const char *reason);
bool safe(const Measurement &m, Mode mode);
void parseMeasurement(char *line);
void commsTask(void *);
bool elapsed();
void advance();
void testTask(void *);
void safetyTask(void *);
void loggingTask(void *);

static const int RS485_RX_PIN = 16;
static const int RS485_TX_PIN = 17;
static const int RS485_DIR_PIN = 27;  // XY-017 R/T direction pin; LOW receive, HIGH send
static const uint32_t BAUD = 115200;
static const uint32_t UPDATE_MS = 500;
static const uint32_t COMMS_TIMEOUT_MS = 1500;
static const float MAX_VOLTAGE_V = 9.30f;
static const float MIN_VOLTAGE_V = 6.00f;
static const float MAX_TEMPERATURE_C = 55.0f;
static const float MAX_CHARGE_A = 6.50f;
static const float MAX_DISCHARGE_A = 5.00f;
static const uint32_t CHARGE_PULSE_MS = 10000;
static const uint32_t REST_MS = 30000;
static const uint32_t DISCHARGE_PULSE_MS = 10000;
static const uint32_t CHARGE_HOLD_MS = 15UL * 60UL * 1000UL;

Measurement daq[2] = {{1,0,0,0,0,0,0,false}, {2,0,0,0,0,0,0,false}};
volatile TestState state = IDLE;
volatile bool faultLatched = false;
uint32_t stateStartedAt = 0;
uint32_t recordSequence = 0;

uint16_t crc16(const uint8_t *data, size_t length) {
  uint16_t crc = 0xFFFF;
  for (size_t i = 0; i < length; ++i) {
    crc ^= data[i];
    for (uint8_t b = 0; b < 8; ++b) crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : (crc >> 1);
  }
  return crc;
}

void rs485Receive() { digitalWrite(RS485_DIR_PIN, LOW); }
void rs485Send(const char *message) {
  digitalWrite(RS485_DIR_PIN, HIGH);
  delayMicroseconds(20);
  Serial2.print(message);
  Serial2.flush();
  delayMicroseconds(40);
  rs485Receive();
}

const char *modeName(Mode m) {
  return m == MODE_CHARGE ? "CHARGE" : (m == MODE_DISCHARGE ? "DISCHARGE" : "OFF");
}
Mode commandedMode() {
  return (state == CHARGE_PULSE || state == CHARGE_HOLD) ? MODE_CHARGE : (state == DISCHARGE_PULSE ? MODE_DISCHARGE : MODE_OFF);
}
float commandedCurrent() {
  return state == CHARGE_PULSE ? MAX_CHARGE_A : (state == DISCHARGE_PULSE ? MAX_DISCHARGE_A : (state == CHARGE_HOLD ? 2.1667f : 0.0f));
}

void commandOne(uint8_t address, Mode mode, float currentA) {
  char body[48], frame[64];
  snprintf(body, sizeof(body), "C,%u,%s,%.4f", address, modeName(mode), currentA);
  snprintf(frame, sizeof(frame), "@%s,%04X\n", body, crc16((const uint8_t *)body, strlen(body)));
  rs485Send(frame);
}
void commandAll(Mode mode, float currentA) {
  commandOne(1, mode, currentA);
  commandOne(2, mode, currentA);
}
void stopSafely(const char *reason) {
  commandAll(MODE_OFF, 0.0f);
  faultLatched = true;
  state = FAULT;
  Serial.printf("EVENT,FAULT,%lu,%s\n", millis(), reason);
}

bool safe(const Measurement &m, Mode mode) {
  if (!m.valid || millis() - m.receivedAt > COMMS_TIMEOUT_MS || m.status != 0) return false;
  if (!isfinite(m.voltageV) || !isfinite(m.currentA) || !isfinite(m.temperatureC)) return false;
  if (m.voltageV > MAX_VOLTAGE_V || m.temperatureC > MAX_TEMPERATURE_C) return false;
  if (mode == MODE_DISCHARGE && m.voltageV < MIN_VOLTAGE_V) return false;
  if (mode == MODE_CHARGE && m.currentA > MAX_CHARGE_A * 1.10f) return false;
  if (mode == MODE_DISCHARGE && fabsf(m.currentA) > MAX_DISCHARGE_A * 1.10f) return false;
  return true;
}

void parseMeasurement(char *line) {
  if (line[0] != '@') return;
  char *last = strrchr(line, ',');
  if (!last) return;
  uint16_t claimed = (uint16_t)strtoul(last + 1, nullptr, 16);
  *last = 0;
  if (crc16((const uint8_t *)(line + 1), strlen(line + 1)) != claimed) return;
  char *save = nullptr;
  char *kind = strtok_r(line + 1, ",", &save);
  char *addr = strtok_r(nullptr, ",", &save);
  char *seq = strtok_r(nullptr, ",", &save);
  char *volts = strtok_r(nullptr, ",", &save);
  char *amps = strtok_r(nullptr, ",", &save);
  char *temp = strtok_r(nullptr, ",", &save);
  char *status = strtok_r(nullptr, ",", &save);
  if (!kind || strcmp(kind,"M") || !addr || !seq || !volts || !amps || !temp || !status) return;
  int i = atoi(addr) - 1;
  if (i < 0 || i > 1) return;
  daq[i] = {(uint8_t)(i + 1), strtoul(seq,nullptr,10), strtof(volts,nullptr), strtof(amps,nullptr), strtof(temp,nullptr), (uint16_t)strtoul(status,nullptr,0), millis(), true};
}

void commsTask(void *) {
  char line[128]; size_t used = 0; uint32_t nextPoll = 0;
  for (;;) {
    while (Serial2.available()) {
      char c = (char)Serial2.read();
      if (c == '\n' || used == sizeof(line) - 1) { line[used] = 0; if (used) parseMeasurement(line); used = 0; }
      else if (c != '\r') line[used++] = c;
    }
    if ((int32_t)(millis() - nextPoll) >= 0) {
      for (uint8_t a = 1; a <= 2; ++a) { char b[12], f[28]; snprintf(b,sizeof(b),"R,%u",a); snprintf(f,sizeof(f),"@%s,%04X\n",b,crc16((const uint8_t *)b,strlen(b))); rs485Send(f); }
      nextPoll = millis() + UPDATE_MS;
    }
    vTaskDelay(pdMS_TO_TICKS(5));
  }
}

bool elapsed() {
  uint32_t e = millis() - stateStartedAt;
  return (state == CHARGE_PULSE && e >= CHARGE_PULSE_MS) || ((state == REST_AFTER_CHARGE || state == REST_AFTER_DISCHARGE) && e >= REST_MS) || (state == DISCHARGE_PULSE && e >= DISCHARGE_PULSE_MS) || (state == CHARGE_HOLD && e >= CHARGE_HOLD_MS);
}
void advance() {
  stateStartedAt = millis();
  if (state == CHARGE_PULSE) state = REST_AFTER_CHARGE;
  else if (state == REST_AFTER_CHARGE) state = DISCHARGE_PULSE;
  else if (state == DISCHARGE_PULSE) state = REST_AFTER_DISCHARGE;
  else if (state == REST_AFTER_DISCHARGE) state = CHARGE_HOLD;
  else if (state == CHARGE_HOLD) state = COMPLETE;
  commandAll(commandedMode(), commandedCurrent());
  Serial.printf("EVENT,STATE,%lu,%u\n", millis(), (unsigned)state);
}
void testTask(void *) { for (;;) { if (!faultLatched && state >= CHARGE_PULSE && state <= CHARGE_HOLD && elapsed()) advance(); vTaskDelay(pdMS_TO_TICKS(20)); } }
void safetyTask(void *) { for (;;) { Mode m = commandedMode(); if (!faultLatched && m != MODE_OFF) for (uint8_t i=0;i<2;i++) if (!safe(daq[i],m)) { stopSafely("measurement, communication, or safety limit"); break; } vTaskDelay(pdMS_TO_TICKS(25)); } }
void loggingTask(void *) { for (;;) { if (state != IDLE) for (uint8_t i=0;i<2;i++) { Measurement &m=daq[i]; Serial.printf("DATA,%lu,%lu,%u,%.4f,%.4f,%.2f,%.4f,%u,%u,%u\n",millis(),++recordSequence,m.address,m.voltageV,m.currentA,m.temperatureC,commandedCurrent(),(unsigned)state,m.status,m.valid?1:0); } vTaskDelay(pdMS_TO_TICKS(UPDATE_MS)); } }

void setup() {
  Serial.begin(BAUD);
  Serial2.begin(BAUD, SERIAL_8N1, RS485_RX_PIN, RS485_TX_PIN);
  pinMode(RS485_DIR_PIN, OUTPUT); rs485Receive();
  commandAll(MODE_OFF, 0.0f);
  xTaskCreatePinnedToCore(commsTask,"RS485",4096,nullptr,3,nullptr,0);
  xTaskCreatePinnedToCore(testTask,"Test",3072,nullptr,2,nullptr,1);
  xTaskCreatePinnedToCore(safetyTask,"Safety",3072,nullptr,4,nullptr,1);
  xTaskCreatePinnedToCore(loggingTask,"Logger",4096,nullptr,1,nullptr,0);
  Serial.println("READY: DAQ 01 and DAQ 02 must report valid readings before START.");
}
void loop() {
  static String s;
  while (Serial.available()) { char c=(char)Serial.read(); if (c=='\n' || c=='\r') { s.trim(); s.toUpperCase(); if (s=="START" && (state==IDLE || state==COMPLETE || state==FAULT)) { faultLatched=false; daq[0].valid=daq[1].valid=false; state=CHARGE_PULSE; stateStartedAt=millis(); commandAll(MODE_CHARGE,MAX_CHARGE_A); Serial.println("EVENT,START"); } else if (s=="STOP") { commandAll(MODE_OFF,0); state=IDLE; faultLatched=false; Serial.println("EVENT,STOP"); } s=""; } else s+=c; }
  vTaskDelay(pdMS_TO_TICKS(10));
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik