Community project
Variable LPG Gas Vending Kiosk
This project demonstrates a supervisory controller for an LPG gas vending kiosk that accepts mobile payments and calculates dispensing amounts based on customer requests. Built around an ESP32 microcontroller and touchscreen interface, the prototype manages transaction states, payment verification, and delivery tracking—but deliberately does not control valves, pumps, or safety systems, which remain under certified professional equipment.
The guide provides a wiring diagram for the ESP32 and touchscreen display, a complete parts list, Arduino firmware with transaction state management, and step-by-step assembly instructions for the non-hazardous terminal prototype. Readers will learn how to structure a supervisory kiosk interface while understanding the critical safety boundaries that require integration with certified dispenser and safety controllers before real-world LPG deployment.
Wiring diagram
Assemble it in 3 steps
1. Use this only as a non-hazardous terminal prototype
Place the Waveshare ESP32-S3 touchscreen board on a desk or in a customer enclosure outside the LPG hazardous area. The board has no direct connection to valves, pumps, leak detectors, or emergency shutdown equipment.
- Keep the board and its USB cable in the safe customer or control-room side of the installation.
- Do not place ordinary ESP32 electronics in an LPG hazardous area; a spark or hot part could ignite released gas.
2. Power the touchscreen board
Plug a suitable USB-C lead into the board’s USB-C power port. This powers only the supervisory prototype screen and firmware.
- Use the board’s normal USB-C input while developing the user interface.
- Do not use this USB-powered prototype as a safety controller or connect it directly to LPG equipment.
3. Have certified professionals integrate the real plant
A licensed LPG designer, hazardous-area electrician, and functional-safety integrator must separately specify, install, inspect, and commission the certified dispenser controller, safety controller, gas detection, approved measurement equipment, isolation valves, grounding, ventilation, and emergency stops.
- Require documented alarm simulations, calibration certificates, commissioning records, and local regulatory approval before any live LPG operation.
- The project firmware does not replace legal-for-trade metering, safety certification, pressure-equipment design, or regulatory approval.
Deploy the firmware
#include <Arduino.h>
// LPG ATM supervisory prototype.
// This controller never drives valves, pumps, or emergency-isolation equipment.
// A certified dispenser controller and an independent safety controller own those functions.
constexpr float PRICE_PER_KG_KES = 150.00f;
constexpr float MAX_SALE_KES = 10000.00f;
constexpr float DISPENSE_TOLERANCE_KG = 0.005f;
enum class SessionState : uint8_t {
IDLE,
PAYMENT_PENDING,
PAYMENT_CONFIRMED,
DISPENSING,
COMPLETE,
FAULT
};
struct Transaction {
uint32_t id = 0;
float requestedKes = 0.0f;
float targetKg = 0.0f;
float deliveredKg = 0.0f;
bool paymentVerified = false;
SessionState state = SessionState::IDLE;
};
Transaction transaction;
uint32_t nextTransactionId = 10001;
const char *stateName(SessionState state) {
switch (state) {
case SessionState::IDLE: return "IDLE";
case SessionState::PAYMENT_PENDING: return "PAYMENT_PENDING";
case SessionState::PAYMENT_CONFIRMED: return "PAYMENT_CONFIRMED";
case SessionState::DISPENSING: return "DISPENSING";
case SessionState::COMPLETE: return "COMPLETE";
case SessionState::FAULT: return "FAULT";
}
return "UNKNOWN";
}
void printStatus() {
Serial.printf("STATUS id=%lu state=%s price_kes_per_kg=%.2f requested_kes=%.2f target_kg=%.3f delivered_kg=%.3f payment_verified=%s\n",
static_cast<unsigned long>(transaction.id), stateName(transaction.state), PRICE_PER_KG_KES,
transaction.requestedKes, transaction.targetKg, transaction.deliveredKg,
transaction.paymentVerified ? "yes" : "no");
}
void startPayment(float amountKes) {
if (amountKes <= 0.0f || amountKes > MAX_SALE_KES) {
Serial.println("REJECTED invalid amount");
return;
}
transaction.id = nextTransactionId++;
transaction.requestedKes = amountKes;
transaction.targetKg = amountKes / PRICE_PER_KG_KES;
transaction.deliveredKg = 0.0f;
transaction.paymentVerified = false;
transaction.state = SessionState::PAYMENT_PENDING;
Serial.printf("PAYMENT_REQUEST id=%lu amount_kes=%.2f target_kg=%.3f\n",
static_cast<unsigned long>(transaction.id), amountKes, transaction.targetKg);
Serial.println("Await a server-verified M-Pesa callback. A browser/backend, not this terminal, stores Daraja credentials.");
}
void acceptVerifiedPayment(uint32_t id) {
if (transaction.state != SessionState::PAYMENT_PENDING || id != transaction.id) {
Serial.println("REJECTED payment confirmation does not match the pending transaction");
return;
}
transaction.paymentVerified = true;
transaction.state = SessionState::PAYMENT_CONFIRMED;
Serial.printf("PAYMENT_CONFIRMED id=%lu. A certified controller may now evaluate its own safety permissives.\n",
static_cast<unsigned long>(id));
}
void updateDeliveredMass(float deliveredKg) {
if (!transaction.paymentVerified || (transaction.state != SessionState::PAYMENT_CONFIRMED && transaction.state != SessionState::DISPENSING)) {
Serial.println("REJECTED no verified transaction is authorized");
return;
}
if (deliveredKg < transaction.deliveredKg || deliveredKg < 0.0f) {
transaction.state = SessionState::FAULT;
Serial.println("FAULT non-monotonic or invalid approved meter value; operator reconciliation required");
return;
}
transaction.deliveredKg = deliveredKg;
transaction.state = SessionState::DISPENSING;
const float remaining = transaction.targetKg - transaction.deliveredKg;
Serial.printf("PROGRESS id=%lu delivered_kg=%.3f remaining_kg=%.3f\n",
static_cast<unsigned long>(transaction.id), transaction.deliveredKg, remaining > 0.0f ? remaining : 0.0f);
if (transaction.deliveredKg + DISPENSE_TOLERANCE_KG >= transaction.targetKg) {
transaction.state = SessionState::COMPLETE;
Serial.printf("RECEIPT id=%lu paid_kes=%.2f price_kes_per_kg=%.2f target_kg=%.3f delivered_kg=%.3f\n",
static_cast<unsigned long>(transaction.id), transaction.requestedKes, PRICE_PER_KG_KES,
transaction.targetKg, transaction.deliveredKg);
}
}
void processCommand(String line) {
line.trim();
if (line.equalsIgnoreCase("STATUS")) {
printStatus();
} else if (line.startsWith("AMOUNT ")) {
startPayment(line.substring(7).toFloat());
} else if (line.startsWith("PAYMENT_OK ")) {
acceptVerifiedPayment(static_cast<uint32_t>(line.substring(11).toInt()));
} else if (line.startsWith("MASS ")) {
updateDeliveredMass(line.substring(5).toFloat());
} else if (line.equalsIgnoreCase("TRIP")) {
transaction.state = SessionState::FAULT;
Serial.println("FAULT recorded. This terminal does not reset or bypass the independent safety system.");
} else if (line.equalsIgnoreCase("RESET")) {
transaction = Transaction{};
Serial.println("Terminal session reset. Certified equipment still requires its own approved reset and inspection process.");
} else if (line.length() > 0) {
Serial.println("Commands: AMOUNT <KES>, PAYMENT_OK <id>, MASS <kg>, STATUS, TRIP, RESET");
}
}
void setup() {
Serial.begin(115200);
delay(200);
Serial.println("LPG ATM supervisory prototype started");
Serial.printf("Configured LPG price: KES %.2f per kg\n", PRICE_PER_KG_KES);
Serial.println("No direct dispensing outputs are enabled on this ESP32.");
Serial.println("Commands: AMOUNT <KES>, PAYMENT_OK <id>, MASS <kg>, STATUS, TRIP, RESET");
}
void loop() {
static String command;
while (Serial.available() > 0) {
const char incoming = static_cast<char>(Serial.read());
if (incoming == '\n' || incoming == '\r') {
if (command.length() > 0) {
processCommand(command);
command = "";
}
} else if (command.length() < 96) {
command += incoming;
}
}
delay(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.




