I’ve just finished another little ESP8266 project, and although the result isn’t particularly complicated, the development experience was interesting enough to be worth writing about.
The hardware is about as simple as it gets: an inexpensive ESP8266 NodeMCU board, a small (again inexpensive) 128×64 SSD1306 OLED display and a single WS2812B RGB LED.
The job of the device is to sit there continuously pinging an IP address and give me an immediate indication of what’s happening.
The OLED displays the target IP address, the current ping result and latency, while the RGB LED gives a quick visual indication of performance. There’s also a heartbeat indicator, a persistent count of pings taking more than 500ms, a reset button and a Web interface for changing the target IP address.
And here’s the bit I found most interesting: The ESP8266 is using only about 41% of its RAM and 43% of its flash. That is after adding all of this functionality.
What the little tester does
The finished unit currently provides:
- Continuous ping testing every five seconds
- User-selectable IP address
- OLED display showing the current result
- Ping latency in milliseconds
- Different RGB colours according to latency
- A heartbeat indicator
- A persistent count of pings taking more than 500ms
- A Web UI
- Persistent IP address settings
- A persistent OLED on/off setting
- A Web UI button to reset the failure counter
- Startup indication so the first failed ping isn’t incorrectly reported as a failure
- Proper handling of complete ping failures
- Non-volatile storage of the important settings
- No rebooting after 15 minutes without an API client
The OLED is deliberately updated every two seconds rather than every second. I discovered during development that hammering the little ESP8266 with display updates could interfere with Wi-Fi authentication. Two seconds proved to be a good compromise.
The RGB LED is also useful because I can glance at the device without looking at the display. Green means the ping is good, moving through yellow-green and yellow as latency increases, with orange for poor latency and red for a complete failure.
The ESPHome advantage
This is where things got interesting: I’ve done plenty of work with Tasmota over several years (it’s all in the blog), and I still use it extensively though I’ve recently done projects with just about every variation of ESP23 out there.
Tasmota is an excellent piece of software, particularly when you want to turn an ESP into a network-connected switch, sensor or other conventional IoT device but I’ve also found myself running into its limitations when trying to make a small device do something outside the standard Tasmota model.
In particular, I have previously found myself running out of Rules.
You can get an extraordinary amount out of Tasmota Rules, but once a project becomes reasonably complicated, the rule language starts becoming a project in itself.
Then there is Berry: Berry is powerful and can solve problems that are difficult or impossible with ordinary Tasmota Rules. But at that point I’m no longer simply configuring a device. I’m effectively programming another language inside Tasmota and for some jobs I’ve also ended up needing a special Tasmota build containing additional functionality — including ping support.
That all works, but it becomes rather more complicated than I really wanted for a small network tester.
Take a look at my earlier attempts at ping testing using Tasmota and both ESP8266 (custom build) and ESP32.
ESPHome felt much more natural
With ESPHome, I could describe most of the device in YAML and then use ordinary C++ where I actually needed something custom.
The ping library is included as an Arduino library:
libraries: - ESPping@1.0.5
and the actual ping operation is ordinary C++:
IPAddress target(
id(ip1),
id(ip2),
id(ip3),
id(ip4)
);
if (Ping.ping(target, 3)) {
id(ping_ms) = (int) Ping.averageTime();
}
That’s a big difference in mindset: I’m not trying to persuade a device designed around a predefined feature set to do something it wasn’t really intended to do… I’m building an ESPHome device and adding a small amount of C++ to give it the functionality I want.
The OLED is similarly straightforward. ESPHome already knows how to drive the SSD1306, so the custom part is simply what gets drawn on the screen. The same applies to the WS2812B.
And the Web interface, API, OTA updates, Wi-Fi configuration and Home Assistant integration all come along with ESPHome.
The persistent settings were particularly useful
I wanted the ping target to survive a power failure so rather than having to write a complete configuration system, I used persistent ESPHome globals for the four IP address octets.
So I can change: 192.168.1.1 to something like: 8.8.4.4 from the Web UI and the setting survives a reboot.
The failure counter is persistent too. That means the little board can be left running for days or weeks (ptrf on UPS) and I can see how many measured pings have exceeded my 500ms threshold.
This isn’t just a ping display
One thing I deliberately built into the tester was a distinction between different types of failure: A failed ping during startup doesn’t immediately result in a big red FAILED indication.
The device first shows: SETTING UP. Once it has successfully established that pinging is working, subsequent failures are treated as genuine failures.
There’s also an important distinction between: PING FAILED and: PING TOOK >500ms
The persistent counter only records the latter. That’s useful because a completely lost ping and a very slow but successful ping are different things when you’re trying to diagnose a network.
And the memory usage? Are you ready?
This is probably the part that surprised me most: After adding all of this functionality, the ESP8266 build is sitting at approximately:
41% RAM used and 43% Flash used
That’s remarkably comfortable for an ESP8266. I was expecting this little project to push the old chip much harder once the Web UI, OLED, RGB LED, ping library, persistent storage and Home Assistant API were all involved.
It hasn’t. There’s still plenty of headroom.
What about an ESP32-C3?
An ESP32-C3 would obviously be more capable hardware. You get a considerably more modern MCU architecture, substantially more processing capability, more RAM and generally a much larger amount of breathing room for a project like this.
If I were starting a new design and already had ESP32-C3 boards available, I would have no hesitation using one… but that’s actually part of the point of this experiment.
I didn’t need one.
The humble ESP8266 is doing the job perfectly well.
For a device whose primary job is:
Wi-Fi → ping something → display the result → flash an LED
the ESP8266 is still entirely adequate.
An ESP32-C3 would give me more headroom for future additions — perhaps more sensors, a more sophisticated display, additional network tests or other functionality — but it wouldn’t make the current ping tester noticeably better. The ESP8266 is already comfortably within its limits and I coudl add a lot moer.
ESP8266 versus ESP32-C3
For this particular application I’d put it roughly like this:
| ESP8266 (nodeMCU-type) | ESP32-C3 | |
|---|---|---|
| Ping tester | More than adequate | Easily adequate |
| OLED | More than adequate | Easily adequate |
| WS2812 | More than adequate | Easily adequate |
| Web UI | Works well | More headroom |
| Home Assistant API | Works well | More headroom |
| RAM | 41% used in this project | Much more available |
| Flash | 43% used in this project | Much more available |
| Wi-Fi | 2.4GHz | 2.4GHz |
| Future expansion | Reasonable | Much better |
| Cost | Extremely cheap | Very cheap |
| Existing stock | I already had them | Also readily available |
So if you have an old NodeMCU-type ESP8266 sitting in the drawer, don’t throw it away. It can still do quite a lot.
The bigger lesson
For me, the interesting part of this project isn’t the ping tester. It’s that ESPHome has blurred the boundary between configuration and programming in a very useful way.
I can use YAML for all the things ESPHome already understands:
- Wi-Fi
- Web UI
- API
- OTA
- GPIO
- LEDs
- displays
- sensors
- persistent values
And when I need something ESPHome doesn’t provide directly, I can drop into ordinary C++.
That is considerably more comfortable than trying to squeeze an increasingly complicated application into Tasmota Rules, then moving into Berry, and occasionally discovering that I need a specially compiled Tasmota build to get the functionality I want.
Tasmota remains an excellent tool – and the timespan of it’s continued development says a lot, and for many ESP projects I would still choose it but for this particular job, ESPHome was simply the more comfortable development environment.
And the fact that the finished application is sitting at roughly 41% RAM and 43% flash on an ESP8266 suggests that there’s still plenty of life left in these old boards.
Final Notes
Why have an on-off switch for the OLED? Unlike the RGB LED which should run for years, these little OLEDs have an on-life measured in months (bitter experience). Also a final comment about the NodeMCU board – it makes a handy base for the OLED display and I just happen to have a number of single ws2812 LEDs on little boards that also glue easily to the remaining space on the NodeMCU. My only gripe is the old-style microUSB connectors but you can’t have everything… before today’s ridiculous EU duty on small packages from AliExpress – I think I paid 1.50 euros for the NodeMCUs.
Also – note that rather than the single set of secrets for IP address in the code below -or the two IP addresses max for Tasmota, I’ve included all my SSIDs (big house – common password as it happens). Note also that in the code I’ve added a version number which appears in the WebUI and I’m still at 41% and 43% usage respectively – I could keep expanding this forever.
And the ESP8266 isn’t finished yet…
I’ve also been using the little Wemos D1 boards for another project (originally used a NodeMCU then switched – hence 2-part article) — my LoRa-enabled letterbox.
That project uses a Wemos D1 with a Reyax LoRa module to detect when the letterbox is opened and report the event back to the house.
I’ve recently got two more Reyax LoRa modules on their way, courtesy of Reyax, so that project is about to get another boost.
It’s another good example of where these remarkably inexpensive ESP8266 boards still make perfectly capable little IoT devices.
I originally paid less than €2 each for my NodeMCU boards, and even less for the Wemos D1 units.
And now, the current code: Just change IP reference if internally you don’t start at 192.168.1.1
One last thing: This gadget won’t be talking to ESPHOME – it’s standalone so I added a single line to the otherwise empty API: section to stop it rebooting after 15 minutes..
substitutions:
device_name: oled-test
friendly_name: ESP8266 Ping Tester with ESPHome
device_version: "0.1"
code_source: "https://tech.scargill.net"
esphome:
name: ${device_name}
friendly_name: ${friendly_name}
libraries:
- ESPping@1.0.5
includes:
- <ESPping.h>
on_boot:
priority: -100
then:
- light.turn_on:
id: rgb_led
red: 100%
green: 40%
blue: 0%
esp8266:
board: nodemcuv2
restore_from_flash: true
logger:
level: DEBUG
api:
reboot_timeout: 0s
web_server:
version: 3
ota:
- platform: esphome
wifi:
networks:
- ssid: !secret wifi_ssid
password: !secret wifi_password
- ssid: !secret wifi_ssid2
password: !secret wifi_password
- ssid: !secret wifi_ssid3
password: !secret wifi_password
- ssid: !secret wifi_ssid4
password: !secret wifi_password
- ssid: !secret wifi_ssid5
password: !secret wifi_password
power_save_mode: none
post_connect_roaming: true
ap:
ssid: "${device_name} Fallback"
password: "12345678"
captive_portal:
preferences:
flash_write_interval: 10s
# ==================================================
# GLOBALS
# ==================================================
globals:
- id: ping_state
type: int
restore_value: no
initial_value: "0"
- id: ping_ms
type: int
restore_value: no
initial_value: "0"
- id: heartbeat
type: bool
restore_value: no
initial_value: "false"
# Persistent ping target IP
- id: ip1
type: int
restore_value: yes
initial_value: "192"
- id: ip2
type: int
restore_value: yes
initial_value: "168"
- id: ip3
type: int
restore_value: yes
initial_value: "1"
- id: ip4
type: int
restore_value: yes
initial_value: "1"
# Persistent count of successful pings taking >500ms
- id: failed_ping_count
type: uint32_t
restore_value: yes
initial_value: "0"
# ==================================================
# FONT
# ==================================================
font:
- file: "gfonts://Roboto"
id: font_small
size: 12
- file: "gfonts://Roboto"
id: font_heading
size: 16
# ==================================================
# I2C OLED
# ==================================================
i2c:
sda: D2
scl: D1
# ==================================================
# OLED DISPLAY
# ==================================================
display:
- platform: ssd1306_i2c
model: "SSD1306 128x64"
address: 0x3C
update_interval: 2s
lambda: |-
// OLED disabled
if (!id(oled_display).state) {
it.fill(COLOR_OFF);
return;
}
// Header
it.print(0, 0, id(font_heading), "PING TEST");
// Heartbeat
if (id(heartbeat)) {
it.print(118, 0, id(font_small), "*");
}
// Ping status
if (id(ping_state) == 0) {
it.print(0, 20, id(font_small), "SETTING UP");
} else if (id(ping_state) == 1) {
it.printf(
0, 20,
id(font_small),
"OK %d ms",
id(ping_ms)
);
} else {
if (id(ping_ms) > 500) {
it.printf(
0, 20,
id(font_small),
"FAILED %d ms",
id(ping_ms)
);
} else {
it.print(0, 20, id(font_small), "FAILED");
}
}
// Target IP
it.printf(
0, 36,
id(font_small),
"%d.%d.%d.%d",
id(ip1),
id(ip2),
id(ip3),
id(ip4)
);
// Persistent failure counter
char fail_text[20];
sprintf(fail_text, "Failures: %d", (int) id(failed_ping_count));
int fail_width;
int x_offset;
int baseline;
int height;
id(font_small).measure(
fail_text,
&fail_width,
&x_offset,
&baseline,
&height
);
it.print(
128 - fail_width,
50,
id(font_small),
fail_text
);
# ==================================================
# RGB LED
# ==================================================
light:
- platform: neopixelbus
id: rgb_led
type: GRB
variant: WS2812
pin: D6
num_leds: 1
name: "RGB LED"
gamma_correct: 1.0
initial_state:
brightness: 26%
restore_state: all
text_sensor:
- platform: template
name: "Firmware Version"
lambda: |-
return {"${device_version}"};
update_interval: 60s
- platform: template
name: "code_source"
lambda: |-
return {"${code_source}"};
update_interval: 60s
# ==================================================
# PING IP ADDRESS
# ==================================================
number:
- platform: template
name: "Ping IP 1"
id: ping_ip1
min_value: 0
max_value: 255
step: 1
mode: box
lambda: |-
return id(ip1);
set_action:
- lambda: |-
id(ip1) = (int)x;
- platform: template
name: "Ping IP 2"
id: ping_ip2
min_value: 0
max_value: 255
step: 1
mode: box
lambda: |-
return id(ip2);
set_action:
- lambda: |-
id(ip2) = (int)x;
- platform: template
name: "Ping IP 3"
id: ping_ip3
min_value: 0
max_value: 255
step: 1
mode: box
lambda: |-
return id(ip3);
set_action:
- lambda: |-
id(ip3) = (int)x;
- platform: template
name: "Ping IP 4"
id: ping_ip4
min_value: 0
max_value: 255
step: 1
mode: box
lambda: |-
return id(ip4);
set_action:
- lambda: |-
id(ip4) = (int)x;
# ==================================================
# PING FAILURE COUNTER SENSOR
# ==================================================
sensor:
- platform: template
name: "Ping Failures >500ms"
id: failed_ping_counter_sensor
accuracy_decimals: 0
update_interval: 5s
lambda: |-
return (float) id(failed_ping_count);
# ==================================================
# OLED ON/OFF SWITCH
# ==================================================
switch:
- platform: template
name: "OLED Display"
id: oled_display
restore_mode: RESTORE_DEFAULT_ON
optimistic: true
turn_on_action:
- lambda: |-
// OLED enabled
turn_off_action:
- lambda: |-
// OLED disabled
# ==================================================
# RESET PING COUNTER
# ==================================================
button:
- platform: template
name: "Reset Ping Counter"
id: reset_ping_counter
on_press:
- lambda: |-
id(failed_ping_count) = 0;
- component.update: failed_ping_counter_sensor
# ==================================================
# PING TEST
# ==================================================
interval:
- interval: 5s
then:
- lambda: |-
id(heartbeat) = !id(heartbeat);
IPAddress target(
id(ip1),
id(ip2),
id(ip3),
id(ip4)
);
if (Ping.ping(target, 3)) {
id(ping_ms) = (int) Ping.averageTime();
bool was_running = (id(ping_state) != 0);
// Count only successful pings taking longer than 500ms.
// Do not count the first successful ping after startup.
if (was_running && id(ping_ms) > 500) {
id(failed_ping_count)++;
}
if (id(ping_ms) > 500) {
id(ping_state) = 2;
} else {
id(ping_state) = 1;
}
auto call = id(rgb_led).turn_on();
bool local_target =
(id(ip1) == 192 &&
id(ip2) == 168);
if (local_target) {
if (id(ping_ms) < 20) {
call.set_rgb(0.0, 1.0, 0.0);
} else if (id(ping_ms) < 50) {
call.set_rgb(0.5, 1.0, 0.0);
} else if (id(ping_ms) <= 100) {
call.set_rgb(1.0, 1.0, 0.0);
} else {
call.set_rgb(1.0, 0.4, 0.0);
}
} else {
if (id(ping_ms) < 75) {
call.set_rgb(0.0, 1.0, 0.0);
} else if (id(ping_ms) < 125) {
call.set_rgb(0.5, 1.0, 0.0);
} else if (id(ping_ms) <= 175) {
call.set_rgb(1.0, 1.0, 0.0);
} else {
call.set_rgb(1.0, 0.4, 0.0);
}
}
call.perform();
} else {
// Complete ping failure does NOT increment the counter.
if (id(ping_state) == 0) {
// Still starting up.
// Remain in SETTING UP state.
auto call = id(rgb_led).turn_on();
call.set_rgb(1.0, 0.4, 0.0);
call.perform();
} else {
id(ping_state) = 2;
id(ping_ms) = 0;
auto call = id(rgb_led).turn_on();
call.set_rgb(1.0, 0.0, 0.0);
call.perform();
}
}

