PART 1: THE SENSOR
We need this sensor to read the water level inside the tank. For this we'll be using the US-100 Ultrasonic Distance Sensor module. This sensor is 3.3V logic compatible and has a UART mode where it returns a distance reading that takes into account the current temperature for better accuracy. This sensor is wired to a ESP8266 module which is flashed with Tasmota and powered by POE. More info on the software bits after the photos.
Here is the hardware:
That's a WEMOS D1 R2 with the US-100 wired. Power is fed in through a 2M length of outdoor-rated CCTV cable that will be plugged into a 12V POE splitter. This WEMOS board can handle 12V input safely but you can use any ESP8266 board with a voltage converter module. I've removed all of the pins and DC input on this WEMOS board to make it easier to install.
All installed:
The WEMOS module is hard-mounted with M3 screws and spacers but it's important that the ultrasonic sensor is not mounted through the metal cans, I used hot glue on the back of the PCB. Applying pressure or fixation to the metal cans will prevent the sensor from working properly, they must sit loose and free.
The POE adapter was also hot glued in a waterproof CCTV junction box:
You can see the lines that I scored for drilling out the holes, they're 16mm on one axis and 16mm & 39mm on the other axis. Drill out 16mm holes on the two intersections.
This is then mounted to the lid of the water tank, with a small bit cut out for the cable to pass through:
And finally the junction box is hung with a wire, with the cables pointing downwards to discourage water ingress during rains:
For the software, we're using a custom build of Tasmota that incorporates a Serial to TCP bridge for Node-RED to access the sensor's readings. This is because Tasmota does not currently support the UART mode of the US-100 sensor. You can compile your own or use the
Follow the instructions here: https://tasmota.github.io/docs/Serial-to-TCP-Bridge/ while setting baud rate to 9600 with the
PART2: THE CODE
This is the flow I'm currently using, you can replicate it or modify to your preference:
I called the overhead tank 'elevated_pot'. You should have MQTT and optionally InfluxDB configured with Node-RED. You can delete the 'record' node if you don't want or need the database.
Here's the JSON for you to import into Node-RED:
So the flow runs every 5 seconds. On first-run there's a function in the On Start tab of the init & break function node:
We're defining an array here with ten values:
The 5s inject node sends a buffer value of
But before that happens, the init & break node forces a 60 second time-out since the last reading's timestamp, so as not to overwhelm the database with a reading every 5 seconds:
The other reason for this time-out is that it could take more than a few readings to get an usable reading that's been thrice-validated. How that is done is explained in the process, filter & assemble node:
Basically, this code ensures that only three successive & identical readings are accepted as a valid reading. A little leniency is allowed in rounding down the number to the closest 10 liters.
The 1x per min node does what it sounds like it does and triggers the notify? node to decide if to send a notification to telegram:
This code triggers a telegram notification through a link out node. This notification is triggered under two conditions, if there's been 15 minute period since the last notification and the reading has changed, or if the reading has changed more than 10%.
Lastly, the send out percentage node sends out the percentage value to the MQTT server to pass on to any device waiting for an update (part 3 below):
A zero-value is changed to 10% for better visuals on the meter (part 3).
Here are what the notifications look like:
PART 3: THE METER
This device is conveniently powered by USB and connects to MQTT to pull the percentage and display it as a bar graph. It's running Arduino code since Tasmota doesn't have support for the SeeedStudio Grove LED Bar.
It's built using a ESP-01 module , the LED Bar module, and a AMS1117-3.3 voltage regulator on a small proto-pcb plugged into a usb charger:
Everything is stacked for easier debugging and assembly:
The ESP-01 module lacks the pull-up resistors that the ESP-01S has, so I've added two 10K ones:
The Grove LED bar is wider than the proto-pcb so I've had to bend the pins a little:
Here is the Arduino code:
The serial connection is left on since the ESP-01 module has a blue LED connected to the TX pin, so it's used a visual indicator that the module is working and getting updates.
We're using the EspMQTTClient library that handles both Wi-Fi and MQTT: https://github.com/plapointe6/EspMQTTClient
The Grove LED Bar library in the Arduino IDE is out of date, so you'll need to install it and then overwrite it with the updated unreleased files from github: https://github.com/Seeed-Studio/Grove_LED_Bar
Be sure to experiment with the examples to learn more about those libraries.
All configured and working, showing that the tank is 40% full:
Installed in the hallway for everyone to see:
This project has been bouncing around in my head for five or so years now. I'm happy it's finally done, even if it's just a prototype.
If I've skipped something or something isn't clear, ask away, I probably did forget a few key details somewhere.
We need this sensor to read the water level inside the tank. For this we'll be using the US-100 Ultrasonic Distance Sensor module. This sensor is 3.3V logic compatible and has a UART mode where it returns a distance reading that takes into account the current temperature for better accuracy. This sensor is wired to a ESP8266 module which is flashed with Tasmota and powered by POE. More info on the software bits after the photos.
Here is the hardware:
That's a WEMOS D1 R2 with the US-100 wired. Power is fed in through a 2M length of outdoor-rated CCTV cable that will be plugged into a 12V POE splitter. This WEMOS board can handle 12V input safely but you can use any ESP8266 board with a voltage converter module. I've removed all of the pins and DC input on this WEMOS board to make it easier to install.
All installed:
The WEMOS module is hard-mounted with M3 screws and spacers but it's important that the ultrasonic sensor is not mounted through the metal cans, I used hot glue on the back of the PCB. Applying pressure or fixation to the metal cans will prevent the sensor from working properly, they must sit loose and free.
The POE adapter was also hot glued in a waterproof CCTV junction box:
You can see the lines that I scored for drilling out the holes, they're 16mm on one axis and 16mm & 39mm on the other axis. Drill out 16mm holes on the two intersections.
This is then mounted to the lid of the water tank, with a small bit cut out for the cable to pass through:
And finally the junction box is hung with a wire, with the cables pointing downwards to discourage water ingress during rains:
For the software, we're using a custom build of Tasmota that incorporates a Serial to TCP bridge for Node-RED to access the sensor's readings. This is because Tasmota does not currently support the UART mode of the US-100 sensor. You can compile your own or use the
tasmota-zbbridge
pre-compiled binary.Follow the instructions here: https://tasmota.github.io/docs/Serial-to-TCP-Bridge/ while setting baud rate to 9600 with the
TCPBaudRate 9600
command in the console. Be sure to setup rule1 to activate the bridge on boot with the command Rule1 ON System#Boot DO TCPStart 8888,192.168.0.10 ENDON
. The ip address should that of your self-hosted Node-RED instance (not covered in this guide).PART2: THE CODE
This is the flow I'm currently using, you can replicate it or modify to your preference:
I called the overhead tank 'elevated_pot'. You should have MQTT and optionally InfluxDB configured with Node-RED. You can delete the 'record' node if you don't want or need the database.
Here's the JSON for you to import into Node-RED:
JSON:
[
{
"id": "9c1d17307d70c8e7",
"type": "tab",
"label": "Water Supply",
"disabled": false,
"info": "",
"env": []
},
{
"id": "e742fddb55d91320",
"type": "tcp request",
"z": "9c1d17307d70c8e7",
"name": "elevated_pot",
"server": "192.168.0.10",
"port": "8888",
"out": "sit",
"ret": "buffer",
"splitc": " ",
"newline": "",
"trim": false,
"tls": "",
"x": 310,
"y": 180,
"wires": [
[
"9599dd34ec985ecc"
]
]
},
{
"id": "1fd33c29198b6d97",
"type": "inject",
"z": "9c1d17307d70c8e7",
"name": "5s",
"props": [
{
"p": "payload"
}
],
"repeat": "5",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "[\"0x55\"]",
"payloadType": "bin",
"x": 110,
"y": 140,
"wires": [
[
"c3edfc8a8fabd312"
]
]
},
{
"id": "9599dd34ec985ecc",
"type": "function",
"z": "9c1d17307d70c8e7",
"name": "process, filter & assemble",
"func": "if (msg.payload.length != 2) \n// invalid reading; exit \n{\n return null;\n}\n\nvar elevated_pot = flow.get('elevated_pot');\n//node.warn(elevated_pot);\n\nvar radius = elevated_pot[9];\nvar height = msg.payload[0] * 256 + msg.payload[1];\nvar cavity = 22/7 * radius * radius * height;\n// arbitrarily reduce anomalies\nvar minmax = Math.floor(cavity / 10) * 10;\nvar volume = elevated_pot[8] - minmax;\n\nif (volume < 0) \n// below reserve\n{\n volume = 0;\n}\n\nif (volume === elevated_pot[3]) \n// no change; skip checks\n{\n // send reading to db\n var record = true; \n}\nelse if (volume === elevated_pot[2]) \n// passed secondary check\n{\n // update new reading\n flow.set('elevated_pot[3]', volume); \n\n // send reading to db\n var record = true; \n}\nelse if (volume === elevated_pot[1]) \n// passed primary check\n{\n // update secondary check\n flow.set('elevated_pot[2]', volume) \n}\nelse // failed both checks\n{\n // update primary check\n flow.set('elevated_pot[1]', volume); \n \n // clear secondary check\n flow.set('elevated_pot[2]', 0); \n}\n\nif (record) \n{\n // clear both checks\n flow.set('elevated_pot[1]', 0);\n flow.set('elevated_pot[2]', 0);\n\n // record timestamp\n flow.set('elevated_pot[4]', new Date());\n\n // send reading to db\n msg.payload = \n [\n {\n measurement: \"elevated_pot\",\n fields: \n {\n \"Liters\": volume\n },\n tags:\n {\n _sensor: \"US-100\"\n }\n }\n ];\n return msg;\n}\nelse\n{\n return null;\n}\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 510,
"y": 180,
"wires": [
[
"62aa0f16d23499da"
]
]
},
{
"id": "62aa0f16d23499da",
"type": "influxdb batch",
"z": "9c1d17307d70c8e7",
"influxdb": "1234567890ABCDEF",
"precision": "",
"retentionPolicy": "",
"name": "record",
"database": "database",
"precisionV18FluxV20": "ms",
"retentionPolicyV18Flux": "",
"org": "UmbrellaCorporation",
"bucket": "sensors",
"x": 690,
"y": 180,
"wires": []
},
{
"id": "53db5c1222a9636d",
"type": "function",
"z": "9c1d17307d70c8e7",
"name": "notify?",
"func": "var elevated_pot = flow.get('elevated_pot');\n\nvar old_reading = elevated_pot[5];\nvar new_reading = Math.floor(elevated_pot[3] / 100) * 100;\nvar percentage = Math.floor(new_reading / elevated_pot[8] * 100);\n\nvar last_one = elevated_pot[6];\nvar this_run = new Date();\n\nvar notify = false;\n\nif (this_run.getTime() - last_one.getTime() > 900000)\n{\n notify = true;\n}\n\nif (Math.abs(new_reading - old_reading >= elevated_pot[8]/10))\n{\n notify = true;\n} \nelse if (new_reading === old_reading)\n{\n notify = false;\n}\n\nif (notify)\n{\n // set new reading\n flow.set('elevated_pot[5]', new_reading);\n flow.set('elevated_pot[7]', percentage);\n\n // record timestamp\n flow.set('elevated_pot[6]', new Date());\n\n msg.payload =\n {\n chatId: '-1234567890ABC',\n type: 'message',\n content: '*Rooftop Tank at ' \n + percentage \n + '%* \\n_ ' \n + new_reading \n + ' liters_'\n };\n msg.payload.options =\n {\n 'parse_mode': 'MarkdownV2'\n };\n return msg;\n}\nelse \n{\n return null;\n}",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 550,
"y": 140,
"wires": [
[
"288263b022b2458e"
]
]
},
{
"id": "288263b022b2458e",
"type": "link out",
"z": "9c1d17307d70c8e7",
"name": "telegram",
"mode": "link",
"links": [
"4a52930bbe78b3e5",
"097d635a5e4929af",
"bd3bc90dde1594ea",
"73498046dd2c8752"
],
"x": 725,
"y": 140,
"wires": []
},
{
"id": "c3edfc8a8fabd312",
"type": "function",
"z": "9c1d17307d70c8e7",
"name": "init & break",
"func": "// break for 60s between successive valid readings\n\nvar last_one = flow.get('elevated_pot[4]');\nvar this_run = new Date();\n\nif (this_run.getTime() - last_one.getTime() >= 60000)\n{\n return msg;\n}\nelse \n{\n return null;\n}",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "// power-on reset\nif (flow.get('elevated_pot') === undefined) {\n flow.set('elevated_pot', [\n false,\n 0,\n 0,\n 0,\n new Date(),\n 0,\n new Date(),\n 0,\n 2000,\n 0.72\n ])\n}\n",
"finalize": "",
"libs": [],
"x": 130,
"y": 180,
"wires": [
[
"e742fddb55d91320",
"9bfbebd5d8078c65"
]
]
},
{
"id": "9bfbebd5d8078c65",
"type": "delay",
"z": "9c1d17307d70c8e7",
"name": "1x per min",
"pauseType": "rate",
"timeout": "1",
"timeoutUnits": "seconds",
"rate": "1",
"nbRateUnits": "1",
"rateUnits": "minute",
"randomFirst": "1",
"randomLast": "5",
"randomUnits": "seconds",
"drop": true,
"allowrate": false,
"outputs": 1,
"x": 310,
"y": 140,
"wires": [
[
"63edd20ca459180c",
"53db5c1222a9636d"
]
]
},
{
"id": "63edd20ca459180c",
"type": "function",
"z": "9c1d17307d70c8e7",
"name": "send out percentage",
"func": "msg.payload = flow.get('elevated_pot[7]');\nif (msg.payload === 0)\n{\n msg.payload = 10;\n}\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 500,
"y": 100,
"wires": [
[
"de92b574bda3f709"
]
]
},
{
"id": "de92b574bda3f709",
"type": "mqtt out",
"z": "9c1d17307d70c8e7",
"name": "to display",
"topic": "sensors/display/percent/elevated_pot",
"qos": "",
"retain": "",
"respTopic": "",
"contentType": "",
"userProps": "",
"correl": "",
"expiry": "",
"broker": "71efac7808763b18",
"x": 680,
"y": 100,
"wires": []
},
{
"id": "95a6d622911dd5bf",
"type": "influxdb",
"hostname": "192.168.0.11",
"port": "8086",
"protocol": "http",
"database": "sensors",
"name": "InfluxDB",
"usetls": false,
"tls": "",
"influxdbVersion": "2.0",
"url": "http://192.168.0.11:8086",
"rejectUnauthorized": true
},
{
"id": "71efac7808763b18",
"type": "mqtt-broker",
"name": "Mosquitto",
"broker": "192.168.0.12",
"port": "1883",
"clientid": "",
"autoConnect": true,
"usetls": false,
"protocolVersion": "4",
"keepalive": "60",
"cleansession": true,
"autoUnsubscribe": true,
"birthTopic": "",
"birthQos": "0",
"birthRetain": "false",
"birthPayload": "",
"birthMsg": {},
"closeTopic": "",
"closeQos": "0",
"closeRetain": "false",
"closePayload": "",
"closeMsg": {},
"willTopic": "",
"willQos": "0",
"willRetain": "false",
"willPayload": "",
"willMsg": {},
"userProps": "",
"sessionExpiry": ""
}
]
So the flow runs every 5 seconds. On first-run there's a function in the On Start tab of the init & break function node:
JavaScript:
// power-on reset
if (flow.get('elevated_pot') === undefined) {
flow.set('elevated_pot', [
false,
0,
0,
0,
new Date(),
0,
new Date(),
0,
2000,
0.72
])
}
We're defining an array here with ten values:
- whether the water pump is on (future guide)
- first check of the reading
- second check of the reading
- validated reading
- timestamp of the validation
- reading sent as notification
- timestamp of the notification
- percentage value of the reading
- capacity of the tank in liters
- radius of the tank in meters
The 5s inject node sends a buffer value of
0x55
formatted as ["0x55"]
to the sensor module, which then returns a two-byte hex value that's processed with the process, filter & assemble function node.But before that happens, the init & break node forces a 60 second time-out since the last reading's timestamp, so as not to overwhelm the database with a reading every 5 seconds:
JavaScript:
// break for 60s between successive valid readings
var last_one = flow.get('elevated_pot[4]');
var this_run = new Date();
if (this_run.getTime() - last_one.getTime() >= 60000)
{
return msg;
}
else
{
return null;
}
The other reason for this time-out is that it could take more than a few readings to get an usable reading that's been thrice-validated. How that is done is explained in the process, filter & assemble node:
JavaScript:
if (msg.payload.length != 2)
// invalid reading; exit
{
return null;
}
var elevated_pot = flow.get('elevated_pot');
//node.warn(elevated_pot);
var radius = elevated_pot[9];
var height = msg.payload[0] * 256 + msg.payload[1];
var cavity = 22/7 * radius * radius * height;
// arbitrarily reduce anomalies
var minmax = Math.floor(cavity / 10) * 10;
var volume = elevated_pot[8] - minmax;
if (volume < 0)
// below reserve
{
volume = 0;
}
if (volume === elevated_pot[3])
// no change; skip checks
{
// send reading to db
var record = true;
}
else if (volume === elevated_pot[2])
// passed secondary check
{
// update new reading
flow.set('elevated_pot[3]', volume);
// send reading to db
var record = true;
}
else if (volume === elevated_pot[1])
// passed primary check
{
// update secondary check
flow.set('elevated_pot[2]', volume)
}
else
// failed both checks
{
// update primary check
flow.set('elevated_pot[1]', volume);
// clear secondary check
flow.set('elevated_pot[2]', 0);
}
if (record)
{
// clear both checks
flow.set('elevated_pot[1]', 0);
flow.set('elevated_pot[2]', 0);
// record timestamp
flow.set('elevated_pot[4]', new Date());
// send reading to db
msg.payload =
[
{
measurement: "elevated_pot",
fields:
{
"Liters": volume
},
tags:
{
_sensor: "US-100"
}
}
];
return msg;
}
else
{
return null;
}
Basically, this code ensures that only three successive & identical readings are accepted as a valid reading. A little leniency is allowed in rounding down the number to the closest 10 liters.
The 1x per min node does what it sounds like it does and triggers the notify? node to decide if to send a notification to telegram:
JavaScript:
var elevated_pot = flow.get('elevated_pot');
var old_reading = elevated_pot[5];
var new_reading = Math.floor(elevated_pot[3] / 100) * 100;
var percentage = Math.floor(new_reading / elevated_pot[8] * 100);
var last_one = elevated_pot[6];
var this_run = new Date();
var notify = false;
if (this_run.getTime() - last_one.getTime() > 900000)
{
notify = true;
}
if (Math.abs(new_reading - old_reading >= elevated_pot[8]/10))
{
notify = true;
}
else if (new_reading === old_reading)
{
notify = false;
}
if (notify)
{
// set new reading
flow.set('elevated_pot[5]', new_reading);
flow.set('elevated_pot[7]', percentage);
// record timestamp
flow.set('elevated_pot[6]', new Date());
msg.payload =
{
chatId: '-1234567890ABC',
type: 'message',
content: '*Rooftop Tank at '
+ percentage
+ '%* \n_ '
+ new_reading
+ ' liters_'
};
msg.payload.options =
{
'parse_mode': 'MarkdownV2'
};
return msg;
}
else
{
return null;
}
This code triggers a telegram notification through a link out node. This notification is triggered under two conditions, if there's been 15 minute period since the last notification and the reading has changed, or if the reading has changed more than 10%.
Lastly, the send out percentage node sends out the percentage value to the MQTT server to pass on to any device waiting for an update (part 3 below):
JavaScript:
msg.payload = flow.get('elevated_pot[7]');
if (msg.payload === 0)
{
msg.payload = 10;
}
return msg;
A zero-value is changed to 10% for better visuals on the meter (part 3).
Here are what the notifications look like:
PART 3: THE METER
This device is conveniently powered by USB and connects to MQTT to pull the percentage and display it as a bar graph. It's running Arduino code since Tasmota doesn't have support for the SeeedStudio Grove LED Bar.
It's built using a ESP-01 module , the LED Bar module, and a AMS1117-3.3 voltage regulator on a small proto-pcb plugged into a usb charger:
Everything is stacked for easier debugging and assembly:
The ESP-01 module lacks the pull-up resistors that the ESP-01S has, so I've added two 10K ones:
The Grove LED bar is wider than the proto-pcb so I've had to bend the pins a little:
Here is the Arduino code:
C:
#include "EspMQTTClient.h"
#include <Grove_LED_Bar.h>
EspMQTTClient client(
"Wi-Fi SSID",
"Wi-Fi Password",
"MQTT-IP",
"MQTT-username",
"MQTT-password",
"MQTT-client-id",
1883
);
Grove_LED_Bar bar(0, 2, 0, LED_BAR_10);
int reading = 10;
void setup()
{
Serial.begin(9600);
bar.begin();
Serial.println(reading);
}
void onConnectionEstablished()
{
client.subscribe("/sensors/display/percent/elevated_pot", [](const String & payload) {
reading = payload.toInt() / 10;
if (reading <= 10)
{
bar.setLevel(reading);
}
Serial.println(payload);
});
}
void loop()
{
client.loop();
}
The serial connection is left on since the ESP-01 module has a blue LED connected to the TX pin, so it's used a visual indicator that the module is working and getting updates.
We're using the EspMQTTClient library that handles both Wi-Fi and MQTT: https://github.com/plapointe6/EspMQTTClient
The Grove LED Bar library in the Arduino IDE is out of date, so you'll need to install it and then overwrite it with the updated unreleased files from github: https://github.com/Seeed-Studio/Grove_LED_Bar
Be sure to experiment with the examples to learn more about those libraries.
All configured and working, showing that the tank is 40% full:
Installed in the hallway for everyone to see:
This project has been bouncing around in my head for five or so years now. I'm happy it's finally done, even if it's just a prototype.
If I've skipped something or something isn't clear, ask away, I probably did forget a few key details somewhere.
Last edited: