Parts list
| Part | Qty | Approx. | Buy |
|---|---|---|---|
| NodeMCU ESP8266 (CP2102 or CH340) | 1 | ₹280 | Buy NodeMCU ESP8266 (CP2102 or CH340) |
| Load cell, 50 kg bar type + HX711 board | 1 | ₹550 | Buy Load cell, 50 kg bar type + HX711 board |
| 0.96" SSD1306 I2C OLED (4-pin) | 1 | ₹220 | Buy 0.96" SSD1306 I2C OLED (4-pin) |
| Two 12 mm plywood boards (35 × 35 cm) + M6 bolts and spacers | 1 | ₹300 | Buy Two 12 mm plywood boards (35 × 35 cm) + M6 bolts and spacers |
| Known weight for calibration (a sealed 5 kg atta bag) | 1 | ₹0 | Buy Known weight for calibration (a sealed 5 kg atta bag) |
| 5 V 1 A USB adapter + cable | 1 | ₹120 | Buy 5 V 1 A USB adapter + cable |
| Total | ₹1,470 |
Affiliate disclosure: “Buy” links may earn FlashReady a small commission at no extra cost to you. Prices are indicative.
Wiring
| NodeMCU ESP8266 | Part · pin | Wire | Notes |
|---|---|---|---|
| 3V3 | HX711 load cell amplifier · VCC | red | 3.3 V supply keeps DT at 3.3 V logic |
| GND | HX711 load cell amplifier · GND | black | |
| D5 | HX711 load cell amplifier · DT | green | GPIO14 |
| D6 | HX711 load cell amplifier · SCK | yellow | GPIO12 |
| Load cell, 50 kg bar type (4 wires) · Red | HX711 load cell amplifier · E+ | red | Excitation + (check your cell’s colour code) |
| Load cell, 50 kg bar type (4 wires) · Black | HX711 load cell amplifier · E- | black | |
| Load cell, 50 kg bar type (4 wires) · White | HX711 load cell amplifier · A- | white | Swap white/green if the weight reads negative |
| Load cell, 50 kg bar type (4 wires) · Green | HX711 load cell amplifier · A+ | green | |
| D2 | SSD1306 OLED 0.96″ (128×64, I2C) · SDA | blue | GPIO4 (default SDA) |
| D1 | SSD1306 OLED 0.96″ (128×64, I2C) · SCL | yellow | GPIO5 (default SCL) |
| 3V3 | SSD1306 OLED 0.96″ (128×64, I2C) · VCC | red | |
| GND | SSD1306 OLED 0.96″ (128×64, I2C) · GND | black |
Board quirks: The bar load cell must bend: bolt one end to the bottom board and the other end to the top board with spacers, so the middle hangs free. Put the cylinder in the middle of the top board. Gas left = platform weight − the cylinder’s own weight. That “tare weight” is stamped on the collar (for example TW 15.3); enter it in the flasher’s form or in TARE_KG. Each cylinder differs by up to a kilogram, so update it when a new one arrives if you want accuracy. Calibrate once: with the platform empty send <code>z</code> on the Serial Monitor (115200 baud), then put a known weight on it and send <code>c</code>. Copy the two printed numbers into the sketch so they survive a restart. Keep the electronics away from the stove and the regulator. The NodeMCU, OLED and USB adapter go on the wall above the counter; only the 4-wire cable goes down to the platform.
Code
// LPG cylinder gas-left monitor — NodeMCU ESP8266 + HX711 + 50 kg load cell + 0.96" OLED
// HX711: VCC 3V3, DT D5 (GPIO14), SCK D6 (GPIO12). OLED: SDA D2 (GPIO4), SCL D1 (GPIO5), 3V3.
// Gas left = weight on the platform - the empty cylinder's weight (the "TW" stamped on its collar).
// Phone alert through ntfy.sh at 3 kg ("book a refill") and 1 kg ("almost empty").
// Serial commands (115200): z = zero with the platform empty, c = calibrate with KNOWN_KG on it.
#include <ESP8266WiFi.h>
#include <ESP8266HTTPClient.h>
#include <Wire.h>
#include <HX711.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include "fr_settings.h"
const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASS = "YOUR_PASSWORD";
const char* NTFY_TOPIC = "YOUR-PRIVATE-TOPIC";
const char* TARE_KG = "15.3"; // empty cylinder weight, stamped "TW" on the collar
// Filled in from the FlashReady flasher over USB (kept on the board), or type the values above.
FrSetting SETTINGS[] = {
{"wifi_ssid", &WIFI_SSID, true},
{"wifi_pass", &WIFI_PASS, true},
{"ntfy_topic", &NTFY_TOPIC, true},
{"tare_kg", &TARE_KG, false},
};
constexpr uint8_t HX_DT = D5;
constexpr uint8_t HX_SCK = D6;
long ZERO_OFFSET = 0; // raw reading with the platform empty: send z, copy the number here
float COUNTS_PER_KG = 21000.0; // send c with KNOWN_KG on the platform, copy the number here
constexpr float KNOWN_KG = 5.0; // e.g. a 5 kg atta bag
constexpr float FULL_GAS_KG = 14.2; // domestic cylinder
constexpr float REFILL_KG = 3.0;
constexpr float EMPTY_KG = 1.0;
HX711 scale;
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
float tareKg = 15.3;
float hourly[25]; // gas at each of the last 24 hours (index 0 = oldest)
uint8_t hours = 0;
uint32_t lastHour = 0;
bool refillSent = false, emptySent = false, hadCylinder = false;
float lastGas = 0;
void push(const String& msg) {
Serial.println(msg);
if (!*NTFY_TOPIC || WiFi.status() != WL_CONNECTED) return;
WiFiClient client;
HTTPClient http;
http.begin(client, String("http://ntfy.sh/") + NTFY_TOPIC);
http.addHeader("Title", "LPG cylinder");
int code = http.POST(msg);
Serial.printf("ntfy -> %d\n", code);
http.end();
}
void serialCommands() {
if (!Serial.available()) return;
char c = Serial.read();
if (c == 'z') {
ZERO_OFFSET = scale.read_average(20);
scale.set_offset(ZERO_OFFSET);
Serial.printf("ZERO_OFFSET = %ld; // copy into the sketch\n", ZERO_OFFSET);
} else if (c == 'c') {
COUNTS_PER_KG = (scale.read_average(20) - ZERO_OFFSET) / KNOWN_KG;
scale.set_scale(COUNTS_PER_KG);
Serial.printf("COUNTS_PER_KG = %.1f; // copy into the sketch\n", COUNTS_PER_KG);
}
}
void setup() {
Serial.begin(115200);
frSettingsBegin(SETTINGS);
tareKg = atof(TARE_KG);
if (tareKg < 5 || tareKg > 40) tareKg = 15.3;
Wire.begin(D2, D1);
if (!oled.begin(SSD1306_SWITCHCAPVCC, 0x3C)) Serial.println("OLED not found at 0x3C");
oled.setTextColor(SSD1306_WHITE);
scale.begin(HX_DT, HX_SCK);
scale.set_offset(ZERO_OFFSET);
scale.set_scale(COUNTS_PER_KG);
if (*WIFI_SSID && *NTFY_TOPIC) {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
} else {
Serial.println("Phone alerts off — no Wi-Fi name or ntfy topic set. Flash from flashready.in or type them into the code.");
}
Serial.printf("Cylinder tare %.1f kg\n", tareKg);
}
void loop() {
serialCommands();
if (!scale.wait_ready_timeout(500)) {
Serial.println("HX711 not ready - check DT/SCK and power");
delay(1000);
return;
}
float gross = scale.get_units(10);
bool cylinder = gross > 5.0;
float gas = cylinder ? constrain(gross - tareKg, 0.0f, 20.0f) : 0;
int pct = constrain((int)(gas * 100 / FULL_GAS_KG), 0, 100);
// A new (heavier) cylinder: start over.
if (cylinder && (!hadCylinder || gas > lastGas + 5.0)) {
if (hadCylinder) push("New cylinder: " + String(gas, 1) + " kg of gas");
refillSent = emptySent = false;
hours = 0;
lastHour = millis();
}
hadCylinder = cylinder;
lastGas = gas;
// Usage: one sample an hour, up to 24.
if (cylinder && (hours == 0 || millis() - lastHour >= 3600000UL)) {
if (hours == 25) {
memmove(hourly, hourly + 1, 24 * sizeof(float));
hours = 24;
}
hourly[hours++] = gas;
lastHour = millis();
}
float perDay = hours >= 7 ? (hourly[0] - gas) * 24.0f / (hours - 1) : 0;
int daysLeft = perDay > 0.02 ? (int)(gas / perDay) : -1;
if (cylinder && gas < REFILL_KG && !refillSent) {
push("LPG low: " + String(gas, 1) + " kg left. Book a refill now.");
refillSent = true;
}
if (cylinder && gas < EMPTY_KG && !emptySent) {
push("LPG almost empty: " + String(gas, 1) + " kg left.");
emptySent = true;
}
oled.clearDisplay();
oled.setCursor(0, 0);
if (!cylinder) {
oled.setTextSize(2);
oled.println("No");
oled.println("cylinder");
} else {
oled.setTextSize(2);
oled.printf("%.1f kg\n", gas);
oled.drawRect(0, 18, 128, 10, SSD1306_WHITE);
oled.fillRect(2, 20, pct * 124 / 100, 6, SSD1306_WHITE);
oled.setCursor(0, 32);
oled.setTextSize(1);
oled.printf("%d %% of 14.2 kg\n", pct);
if (daysLeft >= 0) oled.printf("~%d days left\n", daysLeft);
else oled.println("Learning usage...");
if (gas < REFILL_KG) oled.println("BOOK A REFILL");
}
oled.display();
Serial.printf("gross=%.2f kg gas=%.2f kg pct=%d days=%d\n", gross, gas, pct, daysLeft);
delay(5000);
}// LPG cylinder gas-left monitor — NodeMCU ESP8266 + HX711 + 50 kg load cell + 0.96" OLED
// HX711: VCC 3V3, DT D5 (GPIO14), SCK D6 (GPIO12). OLED: SDA D2 (GPIO4), SCL D1 (GPIO5), 3V3.
// Gas left = weight on the platform - the empty cylinder's weight (the "TW" stamped on its collar).
// Phone alert through ntfy.sh at 3 kg ("book a refill") and 1 kg ("almost empty").
// Serial commands (115200): z = zero with the platform empty, c = calibrate with KNOWN_KG on it.
#include <ESP8266WiFi.h>
#include <ESP8266HTTPClient.h>
#include <Wire.h>
#include <HX711.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include "fr_settings.h"
const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASS = "YOUR_PASSWORD";
const char* NTFY_TOPIC = "YOUR-PRIVATE-TOPIC";
const char* TARE_KG = "15.3"; // empty cylinder weight, stamped "TW" on the collar
// Filled in from the FlashReady flasher over USB (kept on the board), or type the values above.
FrSetting SETTINGS[] = {
{"wifi_ssid", &WIFI_SSID, true},
{"wifi_pass", &WIFI_PASS, true},
{"ntfy_topic", &NTFY_TOPIC, true},
{"tare_kg", &TARE_KG, false},
};
constexpr uint8_t HX_DT = D5;
constexpr uint8_t HX_SCK = D6;
long ZERO_OFFSET = 0; // raw reading with the platform empty: send z, copy the number here
float COUNTS_PER_KG = 21000.0; // send c with KNOWN_KG on the platform, copy the number here
constexpr float KNOWN_KG = 5.0; // e.g. a 5 kg atta bag
constexpr float FULL_GAS_KG = 14.2; // domestic cylinder
constexpr float REFILL_KG = 3.0;
constexpr float EMPTY_KG = 1.0;
HX711 scale;
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
float tareKg = 15.3;
float hourly[25]; // gas at each of the last 24 hours (index 0 = oldest)
uint8_t hours = 0;
uint32_t lastHour = 0;
bool refillSent = false, emptySent = false, hadCylinder = false;
float lastGas = 0;
void push(const String& msg) {
Serial.println(msg);
if (!*NTFY_TOPIC || WiFi.status() != WL_CONNECTED) return;
WiFiClient client;
HTTPClient http;
http.begin(client, String("http://ntfy.sh/") + NTFY_TOPIC);
http.addHeader("Title", "LPG cylinder");
int code = http.POST(msg);
Serial.printf("ntfy -> %d\n", code);
http.end();
}
void serialCommands() {
if (!Serial.available()) return;
char c = Serial.read();
if (c == 'z') {
ZERO_OFFSET = scale.read_average(20);
scale.set_offset(ZERO_OFFSET);
Serial.printf("ZERO_OFFSET = %ld; // copy into the sketch\n", ZERO_OFFSET);
} else if (c == 'c') {
COUNTS_PER_KG = (scale.read_average(20) - ZERO_OFFSET) / KNOWN_KG;
scale.set_scale(COUNTS_PER_KG);
Serial.printf("COUNTS_PER_KG = %.1f; // copy into the sketch\n", COUNTS_PER_KG);
}
}
void setup() {
Serial.begin(115200);
frSettingsBegin(SETTINGS);
tareKg = atof(TARE_KG);
if (tareKg < 5 || tareKg > 40) tareKg = 15.3;
Wire.begin(D2, D1);
if (!oled.begin(SSD1306_SWITCHCAPVCC, 0x3C)) Serial.println("OLED not found at 0x3C");
oled.setTextColor(SSD1306_WHITE);
scale.begin(HX_DT, HX_SCK);
scale.set_offset(ZERO_OFFSET);
scale.set_scale(COUNTS_PER_KG);
if (*WIFI_SSID && *NTFY_TOPIC) {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
} else {
Serial.println("Phone alerts off — no Wi-Fi name or ntfy topic set. Flash from flashready.in or type them into the code.");
}
Serial.printf("Cylinder tare %.1f kg\n", tareKg);
}
void loop() {
serialCommands();
if (!scale.wait_ready_timeout(500)) {
Serial.println("HX711 not ready - check DT/SCK and power");
delay(1000);
return;
}
float gross = scale.get_units(10);
bool cylinder = gross > 5.0;
float gas = cylinder ? constrain(gross - tareKg, 0.0f, 20.0f) : 0;
int pct = constrain((int)(gas * 100 / FULL_GAS_KG), 0, 100);
// A new (heavier) cylinder: start over.
if (cylinder && (!hadCylinder || gas > lastGas + 5.0)) {
if (hadCylinder) push("New cylinder: " + String(gas, 1) + " kg of gas");
refillSent = emptySent = false;
hours = 0;
lastHour = millis();
}
hadCylinder = cylinder;
lastGas = gas;
// Usage: one sample an hour, up to 24.
if (cylinder && (hours == 0 || millis() - lastHour >= 3600000UL)) {
if (hours == 25) {
memmove(hourly, hourly + 1, 24 * sizeof(float));
hours = 24;
}
hourly[hours++] = gas;
lastHour = millis();
}
float perDay = hours >= 7 ? (hourly[0] - gas) * 24.0f / (hours - 1) : 0;
int daysLeft = perDay > 0.02 ? (int)(gas / perDay) : -1;
if (cylinder && gas < REFILL_KG && !refillSent) {
push("LPG low: " + String(gas, 1) + " kg left. Book a refill now.");
refillSent = true;
}
if (cylinder && gas < EMPTY_KG && !emptySent) {
push("LPG almost empty: " + String(gas, 1) + " kg left.");
emptySent = true;
}
oled.clearDisplay();
oled.setCursor(0, 0);
if (!cylinder) {
oled.setTextSize(2);
oled.println("No");
oled.println("cylinder");
} else {
oled.setTextSize(2);
oled.printf("%.1f kg\n", gas);
oled.drawRect(0, 18, 128, 10, SSD1306_WHITE);
oled.fillRect(2, 20, pct * 124 / 100, 6, SSD1306_WHITE);
oled.setCursor(0, 32);
oled.setTextSize(1);
oled.printf("%d %% of 14.2 kg\n", pct);
if (daysLeft >= 0) oled.printf("~%d days left\n", daysLeft);
else oled.println("Learning usage...");
if (gas < REFILL_KG) oled.println("BOOK A REFILL");
}
oled.display();
Serial.printf("gross=%.2f kg gas=%.2f kg pct=%d days=%d\n", gross, gas, pct, daysLeft);
delay(5000);
}Arduino IDE: keep fr_settings.h in the same folder as the .ino (tab menu → New Tab, same name). Flashing from this page doesn’t need it.
// fr_settings.h — FlashReady on-board settings (Wi-Fi name, phone number, ...) over USB.
//
// Why: the pre-built file on flashready.in is the same for everyone, so your own settings
// can't be inside it. Instead the board asks for them over USB serial and keeps them in
// its EEPROM. If you use Arduino IDE you can still type them into the constants in the
// sketch — this file then changes nothing except a 2-second pause at start-up.
//
// The protocol, in plain words (one text line each way, ending in \n, at 115200 baud):
// 1. Right after Serial.begin(115200) the board prints
// FR?SETTINGS v1 keys=wifi_ssid,wifi_pass missing=wifi_pass
// "missing" lists the required settings that still have no real value.
// 2. If nothing is missing it listens for 2 seconds and then carries on. If something is
// missing it keeps listening, printing the line again every 2 seconds, for up to
// 30 seconds; then it carries on anyway and the sketch skips whatever needs the
// missing value (an alarm still sounds, it just can't send the SMS).
// 3. The flasher answers FR!SET wifi_ssid=My%20Home&wifi_pass=secret
// (values URL-encoded). The board stores them and answers FR!OK, then carries on with
// the new values. Errors: FR!ERR unknown <key>, FR!ERR long <key> (nothing is stored).
// A key that is left out keeps its stored value; "key=" (empty) removes it.
// "FR!SET" with nothing after it means "carry on now": FR!OK, nothing is stored.
// 4. FR!CLEAR wipes everything that was stored, answers FR!OK and keeps listening.
// Other lines are ignored.
//
// A stored value wins over the constant in the sketch. A constant that is still a
// placeholder ("YOUR_SSID", "your-wifi", "+91XXXXXXXXXX") or empty counts as not set.
//
// EEPROM block at FR_SETTINGS_ADDR: 'F' 'R' 0x01, length (uint16, low byte first), then
// "key=value\n" lines. Uses FR_SETTINGS_SIZE bytes; move FR_SETTINGS_ADDR with a #define
// before the #include if your sketch uses EEPROM for something else.
//
// Use in a sketch:
// const char* WIFI_SSID = "YOUR_SSID";
// FrSetting SETTINGS[] = { {"wifi_ssid", &WIFI_SSID, true} }; // key, constant, required
// void setup() { Serial.begin(115200); frSettingsBegin(SETTINGS); ... WiFi.begin(WIFI_SSID, ...); }
// After frSettingsBegin() each constant points at the value to use ("" if none is set).
#pragma once
#include <Arduino.h>
#include <EEPROM.h>
#include <string.h>
#ifndef FR_SETTINGS_ADDR
#define FR_SETTINGS_ADDR 0
#endif
#ifndef FR_SETTINGS_SIZE
#if defined(__AVR__)
#define FR_SETTINGS_SIZE 96 // 2 KB of RAM on an Uno: a phone number fits easily
#else
#define FR_SETTINGS_SIZE 512
#endif
#endif
#ifndef FR_LINE_MAX
#if defined(__AVR__)
#define FR_LINE_MAX 128
#else
#define FR_LINE_MAX 1024
#endif
#endif
#define FR_MAX_KEYS 8
#define FR_WAIT_MAX_MS 30000UL // never wait longer than this for missing settings
#define FR_DATA_MAX (FR_SETTINGS_SIZE - 5)
struct FrSetting {
const char* key; // [a-z0-9_], as in settings.json
const char** value; // the sketch's constant; replaced by the value to use
bool required;
};
// Stored settings in RAM as "key=value\0key=value\0" (only non-empty values).
static char frData[FR_DATA_MAX + 1];
static uint16_t frLen = 0;
static inline void frEepromBegin() {
#if defined(ESP32) || defined(ESP8266)
static bool started = false;
if (!started) EEPROM.begin(FR_SETTINGS_ADDR + FR_SETTINGS_SIZE);
started = true;
#endif
}
static inline void frLoad() {
frEepromBegin();
frLen = 0;
frData[0] = 0;
if (EEPROM.read(FR_SETTINGS_ADDR) != 'F' || EEPROM.read(FR_SETTINGS_ADDR + 1) != 'R' || EEPROM.read(FR_SETTINGS_ADDR + 2) != 1) return;
uint16_t n = EEPROM.read(FR_SETTINGS_ADDR + 3) | (EEPROM.read(FR_SETTINGS_ADDR + 4) << 8);
if (n > FR_DATA_MAX) return;
for (uint16_t i = 0; i < n; i++) {
char c = EEPROM.read(FR_SETTINGS_ADDR + 5 + i);
frData[i] = c == '\n' ? '\0' : c;
}
frData[n] = 0;
frLen = n;
}
static inline void frPut(int addr, uint8_t b) {
#if defined(__AVR__)
EEPROM.update(addr, b);
#else
EEPROM.write(addr, b);
#endif
}
static inline void frSave() {
frEepromBegin();
frPut(FR_SETTINGS_ADDR, 'F');
frPut(FR_SETTINGS_ADDR + 1, 'R');
frPut(FR_SETTINGS_ADDR + 2, 1);
frPut(FR_SETTINGS_ADDR + 3, frLen & 0xFF);
frPut(FR_SETTINGS_ADDR + 4, frLen >> 8);
for (uint16_t i = 0; i < frLen; i++) frPut(FR_SETTINGS_ADDR + 5 + i, frData[i] ? frData[i] : '\n');
#if defined(ESP32) || defined(ESP8266)
EEPROM.commit();
#endif
}
// The stored value for key, or nullptr.
static inline const char* frStored(const char* key) {
size_t kl = strlen(key);
for (uint16_t i = 0; i < frLen; i += strlen(frData + i) + 1) {
if (strncmp(frData + i, key, kl) == 0 && frData[i + kl] == '=') return frData + i + kl + 1;
}
return nullptr;
}
// Empty, "your_…" / "YOUR-…" / "your …", or a "+91XXXX…" phone number.
static inline bool frIsPlaceholder(const char* v) {
if (!v || !*v) return true;
if (strncasecmp(v, "your", 4) == 0 && (v[4] == '_' || v[4] == '-' || v[4] == ' ')) return true;
return v[0] == '+' && strstr(v, "XXXX") != nullptr;
}
// The stored value for key, else the fallback constant; "" if that is a placeholder.
static inline const char* frSettingsGet(const char* key, const char* fallback) {
const char* v = frStored(key);
if (!v) v = fallback;
return frIsPlaceholder(v) ? "" : v;
}
static inline bool frIsMissing(const FrSetting& s) {
return s.required && !*frSettingsGet(s.key, *s.value);
}
static inline void frRequest(FrSetting* s, uint8_t n) {
Serial.print(F("FR?SETTINGS v1 keys="));
for (uint8_t i = 0; i < n; i++) {
if (i) Serial.print(',');
Serial.print(s[i].key);
}
Serial.print(F(" missing="));
bool first = true;
for (uint8_t i = 0; i < n; i++) {
if (!frIsMissing(s[i])) continue;
if (!first) Serial.print(',');
Serial.print(s[i].key);
first = false;
}
Serial.print('\n');
}
static inline void frReply(const __FlashStringHelper* what, const char* key) {
Serial.print(what);
Serial.print(key);
Serial.print('\n');
}
static inline int8_t frHex(char c) {
if (c >= '0' && c <= '9') return c - '0';
c |= 0x20;
return (c >= 'a' && c <= 'f') ? c - 'a' + 10 : -1;
}
// Percent-decodes in place. Drops NUL, CR and LF: they can't be stored.
static inline void frDecode(char* s) {
char* o = s;
for (; *s; s++) {
char c = *s;
if (c == '%' && frHex(s[1]) >= 0 && frHex(s[2]) >= 0) {
c = (char)(frHex(s[1]) << 4 | frHex(s[2]));
s += 2;
}
if (c != '\0' && c != '\n' && c != '\r') *o++ = c;
}
*o = 0;
}
// "k1=v1&k2=v2" (cut short if the line overflowed). Saves and returns true, or replies with an error.
static inline bool frSet(FrSetting* s, uint8_t n, char* p, bool overflow) {
const char* upd[FR_MAX_KEYS] = {};
while (*p) {
char* amp = strchr(p, '&');
bool last = !amp;
if (amp) *amp = 0;
char* eq = strchr(p, '=');
if (eq) *eq = 0;
if (*p) {
if (last && overflow) {
frReply(F("FR!ERR long "), p);
return false;
}
uint8_t i = 0;
while (i < n && strcmp(s[i].key, p) != 0) i++;
if (i == n) {
frReply(F("FR!ERR unknown "), p);
return false;
}
char* v = eq ? eq + 1 : p + strlen(p); // "key" alone = "key="
frDecode(v);
upd[i] = v;
}
if (last) break;
p = amp + 1;
}
// Build the new block: for each key, the new value or the one already stored.
char next[FR_DATA_MAX + 1];
uint16_t len = 0;
for (uint8_t i = 0; i < n; i++) {
const char* v = upd[i] ? upd[i] : frStored(s[i].key);
if (!v || !*v) continue;
size_t kl = strlen(s[i].key), vl = strlen(v);
if (len + kl + vl + 2 > FR_DATA_MAX) {
frReply(F("FR!ERR long "), s[i].key);
return false;
}
memcpy(next + len, s[i].key, kl);
next[len + kl] = '=';
memcpy(next + len + kl + 1, v, vl + 1);
len += kl + vl + 2;
}
memcpy(frData, next, len);
frData[len] = 0;
frLen = len;
frSave();
return true;
}
// Handles one received line. Returns true once settings were saved (boot carries on).
static inline bool frCommand(FrSetting* s, uint8_t n, char* line, bool overflow) {
if (strcmp(line, "FR!CLEAR") == 0) {
frLen = 0;
frData[0] = 0;
frSave();
Serial.print(F("FR!OK\n"));
return false;
}
if (strncmp(line, "FR!SET", 6) != 0 || (line[6] != ' ' && line[6] != 0)) return false; // not for us
char* p = line + 6;
while (*p == ' ') p++;
if (*p && !frSet(s, n, p, overflow)) return false; // empty FR!SET = carry on, store nothing
Serial.print(F("FR!OK\n"));
return true;
}
// Call right after Serial.begin(115200). Prints the request, waits as described at the top
// (30 s at most), then points every constant in the list at the value to use ("" = not set).
static inline void frSettingsBegin(FrSetting* s, uint8_t n) {
frLoad();
char line[FR_LINE_MAX];
uint16_t len = 0;
bool overflow = false;
frRequest(s, n);
unsigned long start = millis(), quietSince = start;
while (millis() - start < FR_WAIT_MAX_MS) {
int c = Serial.read();
if (c >= 0) {
quietSince = millis();
if (c == '\r') continue;
if (c != '\n') {
if (len < FR_LINE_MAX - 1) line[len++] = (char)c;
else overflow = true;
continue;
}
line[len] = 0;
bool saved = frCommand(s, n, line, overflow);
len = 0;
overflow = false;
if (saved) break;
continue;
}
if (millis() - quietSince < 2000) {
delay(1); // also keeps the ESP8266 watchdog fed
continue;
}
len = 0; // drop half a line
overflow = false;
bool missing = false;
for (uint8_t i = 0; i < n; i++) missing |= frIsMissing(s[i]);
if (!missing) break;
frRequest(s, n);
quietSince = millis();
}
for (uint8_t i = 0; i < n; i++) *s[i].value = frSettingsGet(s[i].key, *s[i].value);
}
template <size_t N>
static inline void frSettingsBegin(FrSetting (&s)[N]) {
static_assert(N <= FR_MAX_KEYS, "fr_settings.h: at most FR_MAX_KEYS settings");
frSettingsBegin(s, (uint8_t)N);
}// fr_settings.h — FlashReady on-board settings (Wi-Fi name, phone number, ...) over USB.
//
// Why: the pre-built file on flashready.in is the same for everyone, so your own settings
// can't be inside it. Instead the board asks for them over USB serial and keeps them in
// its EEPROM. If you use Arduino IDE you can still type them into the constants in the
// sketch — this file then changes nothing except a 2-second pause at start-up.
//
// The protocol, in plain words (one text line each way, ending in \n, at 115200 baud):
// 1. Right after Serial.begin(115200) the board prints
// FR?SETTINGS v1 keys=wifi_ssid,wifi_pass missing=wifi_pass
// "missing" lists the required settings that still have no real value.
// 2. If nothing is missing it listens for 2 seconds and then carries on. If something is
// missing it keeps listening, printing the line again every 2 seconds, for up to
// 30 seconds; then it carries on anyway and the sketch skips whatever needs the
// missing value (an alarm still sounds, it just can't send the SMS).
// 3. The flasher answers FR!SET wifi_ssid=My%20Home&wifi_pass=secret
// (values URL-encoded). The board stores them and answers FR!OK, then carries on with
// the new values. Errors: FR!ERR unknown <key>, FR!ERR long <key> (nothing is stored).
// A key that is left out keeps its stored value; "key=" (empty) removes it.
// "FR!SET" with nothing after it means "carry on now": FR!OK, nothing is stored.
// 4. FR!CLEAR wipes everything that was stored, answers FR!OK and keeps listening.
// Other lines are ignored.
//
// A stored value wins over the constant in the sketch. A constant that is still a
// placeholder ("YOUR_SSID", "your-wifi", "+91XXXXXXXXXX") or empty counts as not set.
//
// EEPROM block at FR_SETTINGS_ADDR: 'F' 'R' 0x01, length (uint16, low byte first), then
// "key=value\n" lines. Uses FR_SETTINGS_SIZE bytes; move FR_SETTINGS_ADDR with a #define
// before the #include if your sketch uses EEPROM for something else.
//
// Use in a sketch:
// const char* WIFI_SSID = "YOUR_SSID";
// FrSetting SETTINGS[] = { {"wifi_ssid", &WIFI_SSID, true} }; // key, constant, required
// void setup() { Serial.begin(115200); frSettingsBegin(SETTINGS); ... WiFi.begin(WIFI_SSID, ...); }
// After frSettingsBegin() each constant points at the value to use ("" if none is set).
#pragma once
#include <Arduino.h>
#include <EEPROM.h>
#include <string.h>
#ifndef FR_SETTINGS_ADDR
#define FR_SETTINGS_ADDR 0
#endif
#ifndef FR_SETTINGS_SIZE
#if defined(__AVR__)
#define FR_SETTINGS_SIZE 96 // 2 KB of RAM on an Uno: a phone number fits easily
#else
#define FR_SETTINGS_SIZE 512
#endif
#endif
#ifndef FR_LINE_MAX
#if defined(__AVR__)
#define FR_LINE_MAX 128
#else
#define FR_LINE_MAX 1024
#endif
#endif
#define FR_MAX_KEYS 8
#define FR_WAIT_MAX_MS 30000UL // never wait longer than this for missing settings
#define FR_DATA_MAX (FR_SETTINGS_SIZE - 5)
struct FrSetting {
const char* key; // [a-z0-9_], as in settings.json
const char** value; // the sketch's constant; replaced by the value to use
bool required;
};
// Stored settings in RAM as "key=value\0key=value\0" (only non-empty values).
static char frData[FR_DATA_MAX + 1];
static uint16_t frLen = 0;
static inline void frEepromBegin() {
#if defined(ESP32) || defined(ESP8266)
static bool started = false;
if (!started) EEPROM.begin(FR_SETTINGS_ADDR + FR_SETTINGS_SIZE);
started = true;
#endif
}
static inline void frLoad() {
frEepromBegin();
frLen = 0;
frData[0] = 0;
if (EEPROM.read(FR_SETTINGS_ADDR) != 'F' || EEPROM.read(FR_SETTINGS_ADDR + 1) != 'R' || EEPROM.read(FR_SETTINGS_ADDR + 2) != 1) return;
uint16_t n = EEPROM.read(FR_SETTINGS_ADDR + 3) | (EEPROM.read(FR_SETTINGS_ADDR + 4) << 8);
if (n > FR_DATA_MAX) return;
for (uint16_t i = 0; i < n; i++) {
char c = EEPROM.read(FR_SETTINGS_ADDR + 5 + i);
frData[i] = c == '\n' ? '\0' : c;
}
frData[n] = 0;
frLen = n;
}
static inline void frPut(int addr, uint8_t b) {
#if defined(__AVR__)
EEPROM.update(addr, b);
#else
EEPROM.write(addr, b);
#endif
}
static inline void frSave() {
frEepromBegin();
frPut(FR_SETTINGS_ADDR, 'F');
frPut(FR_SETTINGS_ADDR + 1, 'R');
frPut(FR_SETTINGS_ADDR + 2, 1);
frPut(FR_SETTINGS_ADDR + 3, frLen & 0xFF);
frPut(FR_SETTINGS_ADDR + 4, frLen >> 8);
for (uint16_t i = 0; i < frLen; i++) frPut(FR_SETTINGS_ADDR + 5 + i, frData[i] ? frData[i] : '\n');
#if defined(ESP32) || defined(ESP8266)
EEPROM.commit();
#endif
}
// The stored value for key, or nullptr.
static inline const char* frStored(const char* key) {
size_t kl = strlen(key);
for (uint16_t i = 0; i < frLen; i += strlen(frData + i) + 1) {
if (strncmp(frData + i, key, kl) == 0 && frData[i + kl] == '=') return frData + i + kl + 1;
}
return nullptr;
}
// Empty, "your_…" / "YOUR-…" / "your …", or a "+91XXXX…" phone number.
static inline bool frIsPlaceholder(const char* v) {
if (!v || !*v) return true;
if (strncasecmp(v, "your", 4) == 0 && (v[4] == '_' || v[4] == '-' || v[4] == ' ')) return true;
return v[0] == '+' && strstr(v, "XXXX") != nullptr;
}
// The stored value for key, else the fallback constant; "" if that is a placeholder.
static inline const char* frSettingsGet(const char* key, const char* fallback) {
const char* v = frStored(key);
if (!v) v = fallback;
return frIsPlaceholder(v) ? "" : v;
}
static inline bool frIsMissing(const FrSetting& s) {
return s.required && !*frSettingsGet(s.key, *s.value);
}
static inline void frRequest(FrSetting* s, uint8_t n) {
Serial.print(F("FR?SETTINGS v1 keys="));
for (uint8_t i = 0; i < n; i++) {
if (i) Serial.print(',');
Serial.print(s[i].key);
}
Serial.print(F(" missing="));
bool first = true;
for (uint8_t i = 0; i < n; i++) {
if (!frIsMissing(s[i])) continue;
if (!first) Serial.print(',');
Serial.print(s[i].key);
first = false;
}
Serial.print('\n');
}
static inline void frReply(const __FlashStringHelper* what, const char* key) {
Serial.print(what);
Serial.print(key);
Serial.print('\n');
}
static inline int8_t frHex(char c) {
if (c >= '0' && c <= '9') return c - '0';
c |= 0x20;
return (c >= 'a' && c <= 'f') ? c - 'a' + 10 : -1;
}
// Percent-decodes in place. Drops NUL, CR and LF: they can't be stored.
static inline void frDecode(char* s) {
char* o = s;
for (; *s; s++) {
char c = *s;
if (c == '%' && frHex(s[1]) >= 0 && frHex(s[2]) >= 0) {
c = (char)(frHex(s[1]) << 4 | frHex(s[2]));
s += 2;
}
if (c != '\0' && c != '\n' && c != '\r') *o++ = c;
}
*o = 0;
}
// "k1=v1&k2=v2" (cut short if the line overflowed). Saves and returns true, or replies with an error.
static inline bool frSet(FrSetting* s, uint8_t n, char* p, bool overflow) {
const char* upd[FR_MAX_KEYS] = {};
while (*p) {
char* amp = strchr(p, '&');
bool last = !amp;
if (amp) *amp = 0;
char* eq = strchr(p, '=');
if (eq) *eq = 0;
if (*p) {
if (last && overflow) {
frReply(F("FR!ERR long "), p);
return false;
}
uint8_t i = 0;
while (i < n && strcmp(s[i].key, p) != 0) i++;
if (i == n) {
frReply(F("FR!ERR unknown "), p);
return false;
}
char* v = eq ? eq + 1 : p + strlen(p); // "key" alone = "key="
frDecode(v);
upd[i] = v;
}
if (last) break;
p = amp + 1;
}
// Build the new block: for each key, the new value or the one already stored.
char next[FR_DATA_MAX + 1];
uint16_t len = 0;
for (uint8_t i = 0; i < n; i++) {
const char* v = upd[i] ? upd[i] : frStored(s[i].key);
if (!v || !*v) continue;
size_t kl = strlen(s[i].key), vl = strlen(v);
if (len + kl + vl + 2 > FR_DATA_MAX) {
frReply(F("FR!ERR long "), s[i].key);
return false;
}
memcpy(next + len, s[i].key, kl);
next[len + kl] = '=';
memcpy(next + len + kl + 1, v, vl + 1);
len += kl + vl + 2;
}
memcpy(frData, next, len);
frData[len] = 0;
frLen = len;
frSave();
return true;
}
// Handles one received line. Returns true once settings were saved (boot carries on).
static inline bool frCommand(FrSetting* s, uint8_t n, char* line, bool overflow) {
if (strcmp(line, "FR!CLEAR") == 0) {
frLen = 0;
frData[0] = 0;
frSave();
Serial.print(F("FR!OK\n"));
return false;
}
if (strncmp(line, "FR!SET", 6) != 0 || (line[6] != ' ' && line[6] != 0)) return false; // not for us
char* p = line + 6;
while (*p == ' ') p++;
if (*p && !frSet(s, n, p, overflow)) return false; // empty FR!SET = carry on, store nothing
Serial.print(F("FR!OK\n"));
return true;
}
// Call right after Serial.begin(115200). Prints the request, waits as described at the top
// (30 s at most), then points every constant in the list at the value to use ("" = not set).
static inline void frSettingsBegin(FrSetting* s, uint8_t n) {
frLoad();
char line[FR_LINE_MAX];
uint16_t len = 0;
bool overflow = false;
frRequest(s, n);
unsigned long start = millis(), quietSince = start;
while (millis() - start < FR_WAIT_MAX_MS) {
int c = Serial.read();
if (c >= 0) {
quietSince = millis();
if (c == '\r') continue;
if (c != '\n') {
if (len < FR_LINE_MAX - 1) line[len++] = (char)c;
else overflow = true;
continue;
}
line[len] = 0;
bool saved = frCommand(s, n, line, overflow);
len = 0;
overflow = false;
if (saved) break;
continue;
}
if (millis() - quietSince < 2000) {
delay(1); // also keeps the ESP8266 watchdog fed
continue;
}
len = 0; // drop half a line
overflow = false;
bool missing = false;
for (uint8_t i = 0; i < n; i++) missing |= frIsMissing(s[i]);
if (!missing) break;
frRequest(s, n);
quietSince = millis();
}
for (uint8_t i = 0; i < n; i++) *s[i].value = frSettingsGet(s[i].key, *s[i].value);
}
template <size_t N>
static inline void frSettingsBegin(FrSetting (&s)[N]) {
static_assert(N <= FR_MAX_KEYS, "fr_settings.h: at most FR_MAX_KEYS settings");
frSettingsBegin(s, (uint8_t)N);
}Compiled for nodemcu on esp8266 3.1.2 with HX711 Arduino Library 0.7.5 · 3 Nov 2026
How it works
A 14.2 kg domestic LPG cylinder gives no warning before it runs out — usually in the middle of cooking dinner, and the refill takes a day or two to arrive. Shaking or lifting it to guess is how most of us check. This monitor weighs it continuously and tells you exactly how much gas is left.
How it works
The cylinder stands on a small platform with a 50 kg bar load cell underneath. The HX711 amplifies the cell’s tiny signal and converts it to a 24-bit number; the sketch turns that into kilograms with the zero offset and calibration factor you measure once.
Gas left is the weight on the platform minus the cylinder’s own (tare) weight. A full domestic cylinder holds 14.2 kg, so the OLED shows kilograms, a bar and a percentage.
Once an hour the sketch stores the gas weight. After six hours it works out how many kilograms a day your kitchen uses and divides the gas left by that to estimate days left. When gas drops below 3 kg it sends one ntfy.sh phone alert — “Book a refill now” — and another at 1 kg. Swap in a fresh cylinder and it notices the jump of more than 5 kg, announces the new cylinder and resets its alerts.
Why these parts
- A bar load cell rather than four bathroom-scale half-cells: one cell, four wires, no combinator board. A 50 kg one covers a full cylinder (about 30 kg) with margin.
- HX711 at 3.3 V so its data line matches the ESP8266’s logic. Lower excitation only means a different calibration number.
- ntfy.sh for phone alerts: free, no account, and the topic name is your only secret.
Calibration
- Empty platform, send
z— noteZERO_OFFSET. - Put a known weight (a sealed 5 kg atta bag is close enough, or weigh something at a shop) and send
c— noteCOUNTS_PER_KG. - Copy both into the sketch and flash again.
India notes
The tare weight (TW) and the date of the next hydro-test are stamped on the cylinder’s collar. Never put the platform where it could tilt the cylinder or stretch the rubber hose; the cylinder must always stand upright. Commercial 19 kg cylinders work too — change FULL_GAS_KG to 19.0.
Troubleshooting
The weight drifts by a few hundred grams
Normal for a cheap bar cell over hours, worse with heat from the stove. Shield the platform from the burner, and redo z with the platform empty now and then. ±0.2 kg is fine for “days left”.
“No cylinder” with the cylinder on
The platform reads under 5 kg. Run the calibration again; if the number goes down when you press, swap the white and green wires.
No phone alert at 3 kg
Check the Serial Monitor for “ntfy -> 200”. If it says Phone alerts off, the Wi-Fi name or topic is missing; subscribe to the same topic in the ntfy app.
Upgrades
- Warn about a leak: an unexpected drop of more than 0.2 kg in an hour when nobody cooks (pair it with an MQ-6 gas sensor).
- Track two cylinders (the one in use and the spare) with a second HX711.
- Show a monthly cost: kg used × your refill price ÷ 14.2.



