Introduction: Tim's Precision 4‑Wire Low‑Resistance Meter

About: Retired due to health. Oldish. My background is in Structural Engineering. Also smith of many trades. The majority of my project will be what can be made sat in a chair within arm's reach, on a plotter, 3D pri…

🔧✨ Build a Precision 4‑Wire Low‑Resistance Meter (0.001–10.000 Ω)

A stand‑alone Kelvin‑sense instrument evolved from a simple adapter concept.

🌟 Introduction

Measuring very low resistances is tricky — lead resistance, contact resistance, and meter burden voltage all conspire to ruin your readings. This project shows you how to build a true 4‑wire Kelvin low‑resistance meter that delivers stable, repeatable measurements down into the milliohm range.

This design began life as a simple “Low Ohms Adapter” for a handheld multimeter. Through iterative testing, empirical validation, and workflow refinement, it evolved into a fully self‑contained instrument with its own constant‑current source, sense amplifier, microcontroller, and digital display.

Although the meter is stand‑alone in operation, it’s powered entirely from a standard USB supply — simply plug a USB cable into a power bank, PC, or wall‑wart and the NodeMCU module provides clean, regulated power for the whole system. No special bench supply required. 🔌⚡

If you want to measure solder joints, switch contacts, PCB traces, heater elements, or transformer windings with confidence — this is the tool. 🔍⚡

🧰 Two Ways to Build It

Choose the build style that fits your workflow:

🟩 1. Breadboard Build (Beginner‑Friendly)

Using the included Fritzing layout, makers can assemble the meter using:

  1. Standard plug‑in modules
  2. Passive components
  3. A solderless breadboard
  4. Jumper wires

Perfect for experimentation, learning, and quick prototyping.

🟦 2. PCB Build (Professional Finish)

I’ve designed a through‑hole PCB, available via my shared projects on PCB from PCBWay.

. The PCB acts like a motherboard for the same modules used in the breadboard version.

Benefits:

  1. Cleaner wiring
  2. More robust connections
  3. Easier calibration
  4. Looks like a real instrument 😎

Both builds use the same schematic, same firmware, and same measurement principles, so readers can choose the path that fits their tools and experience.

🔍 Features

  1. 🧪 True 4‑wire Kelvin measurement
  2. 🔌 Constant‑current excitation
  3. 📊 High‑resolution ADC sampling
  4. 🔄 Auto‑zero and lead compensation
  5. 🔋 USB‑powered stand‑alone operation
  6. 🖥️ Optional UART output for PC logging
  7. 🎯 Measures approx. 0.001–10.000 Ω depending on configuration

🧠 How It Works (Concept Overview)

Your meter uses the classic Kelvin method:

  1. Two wires deliver a known constant current through the DUT
  2. Two separate sense wires measure only the voltage drop
  3. The microcontroller computes R=V/I
  4. Sense wires carry almost no current → their resistance doesn’t affect the reading

This eliminates the biggest source of error in low‑ohm measurements.

Think of it as resistance X‑ray vision. 👀⚡

Supplies

Parts List — What You’ll Need

This project uses the same electronics for both builds. The only difference is how the probes connect:

  1. Breadboard build uses flying leads and jumper wires
  2. PCB build uses a 4‑pin Kelvin socket on the board

Core Modules (used in both builds)

  1. NodeMCU ESP8266 module Microcontroller, USB power input, 3.3 V regulator, I²C master, UART, display control
  2. ADS1115 16‑bit I²C ADC module (slim type) Precision differential voltage measurement for sense lines
  3. I²C OLED display module (0.96") Shows resistance readings and status info

Passive Components

  1. R1 – 1 Ω sense resistor 1 W or higher, 1 % tolerance or better
  2. R2 – 120 Ω resistor Bias resistor for transistor
  3. R3, R4 – 100 Ω resistors Signal path / base drive
  4. R5, R6 – 4.7 kΩ resistors I²C pull‑ups to 3.3 V
  5. VR1 – 10 Ω trimmer Fine current adjustment
  6. VR10 – 100 Ω trimmer Corse current adjustment

Semiconductors

  1. ⚙️ Q1 – 2N4401 NPN transistor Drives current through DUT
  2. D1, D2 – 1N4148 diodes Stable voltage drop

️ Switches & Connectors

  1. SW2 – SPDT switch Mode or function select
  2. Probe connection (build-specific)
  3. Breadboard: flying leads or DuPont jumpers to TP1–TP4
  4. PCB: 4‑pin Kelvin socket (2 current, 2 sense)
  5. USB power input Supplied via NodeMCU micro‑USB (from power bank, PC, or wall wart)

Mechanical & Build-Specific

Breadboard Build

  1. Solderless breadboard
  2. Jumper wires
  3. Male header pins for modules
  4. Flying leads for probes

PCB Build

  1. Your PCB from PCBWay through‑hole PCB
  2. Male header pins for modules
  3. 4‑pin Kelvin probe socket
  4. Optional enclosure or panel mount hardware

Step 1: 🧠 Learn the Basics — How Do We Measure Resistance?

🧠

Before we build the meter, let’s understand how resistance is actually measured — and why standard multimeters struggle with very low values.

This Mete is based on the Adaptor in the video.

The video above walks through this in detail. There is a second video to show the completed adapter:

🎥 Tim's Low Ohms Resistance Adaptor [Part 2]

🔍 What Your Multimeter Really Does

Multimeters are fundamentally voltmeters. Every mode — current, resistance, even continuity — is built on measuring voltage.

Here’s how each mode works:

  1. Voltage mode: Just measures the potential difference between the probes. No current is generated.
  2. Current mode: Inserts a shunt resistor in series with the circuit. Measures the voltage across the shunt. Calculates current using I=V/R.
  3. Resistance mode: Generates a small known current, sends it through the unknown resistor, and measures the voltage across it. Calculates resistance using R=V/I.

So even in resistance mode, the meter is still just measuring voltage — and doing a calculation.

⚠️ Why Multimeters Struggle With Low Resistance

Here’s the problem:

  1. The internal current source is tiny — often just a few milliamps or microamps.
  2. For a small resistor (e.g. 0.1 Ω), the voltage drop is too small to resolve accurately.
  3. Lead resistance and contact resistance swamp the reading.
  4. You get unstable, noisy, or completely wrong results.

🛠️ The Adapter Solution — External Current Source

To fix this, we move the current source outside the multimeter.

  1. The adapter generates a stable, predictable current using a transistor, diode reference, and calibrated resistor.
  2. The multimeter goes back to doing what it does best: measuring voltage.
  3. Because the current is fixed, the voltage directly represents resistance.

This is the principle behind the adapter — and it’s the foundation of the stand‑alone meter we’re about to build.

📸 Visual Summary

Here’s a simplified diagram from the video:

  1. 🔵 Blue and 🟠 orange wires carry current through the DUT
  2. 🔴 Red and ⚫ black wires measure voltage across the DUT
  3. The meter reads voltage → you calculate resistance using R=V/I

This is the essence of Kelvin sensing — and it’s what makes our meter accurate down to milliohms.

Based on this lets make a 4-Wire Resistance Meter.

Step 2: 🧱Build the Solderless Breadboard Version

🧱

This version is ideal for experimentation, learning, and quick prototyping. It uses standard modules and passive components, all plugged into a solderless breadboard — no soldering required.

You can follow along using the Fritzing layout provided in the download section. It shows exactly how to place each module and wire the connections.

🧠 What You’re Building

This breadboard version includes:

  1. 🧠 NodeMCU ESP8266 module (USB-powered)
  2. 📐 ADS1115 16-bit I²C ADC module
  3. 🖥️ OLED display (I²C)
  4. 🔧 Constant-current source using transistor, resistors, and trimmers
  5. 🧪 Kelvin probe connections (via jumper wires)
  6. 🔘 Mode switch (SPDT)
  7. 🔋 Powered via USB (from power bank, PC, or wall adapter)

🧰 What You’ll Need

Refer to the full Parts List from Step 1. For this build, you’ll also need:

  1. 🧱 Solderless breadboard
  2. 🔌 Jumper wires (male-to-male)
  3. 📎 Male header pins (for plugging in modules)
  4. 🧪 Flying leads or DuPont jumpers for probe connections

🧭 Breadboard Layout Overview

Here’s how the modules are arranged:

  1. 📦 NodeMCU plugs into one side of the breadboard
  2. 📦 ADS1115 module placed near the sense resistor (R1)
  3. 📦 OLED display mounted at the top or side for visibility
  4. 🔧 Transistor + resistors + trimmers form the current source
  5. 🧪 Probe wires connect to TP1–TP4 (current and sense paths)

Use the Fritzing diagram to match pin positions and wire colours. Make sure to keep the sense wires separate from the current path — this is critical for Kelvin accuracy.

⚠️ Tips for Reliable Breadboarding

  1. 🔍 Double-check all power and ground connections
  2. 🔄 Keep wires short and tidy to reduce noise
  3. 🔬 Place the 1 Ω sense resistor close to the ADS1115 module
  4. 🧪 Use firm probe connections — loose clips will affect readings
  5. 🧼 Clean contact points if readings fluctuate

🧪 Test Before You Power Up

Before plugging in USB power:

  1. ✅ Check for shorts between power rails
  2. ✅ Confirm ADS1115 and OLED are wired correctly (SDA/SCL)
  3. ✅ Verify transistor orientation and resistor values
  4. ✅ Make sure probe wires are not swapped (current vs sense)

Once everything is in place, you can plug in USB power to the NodeMCU.

💡 Note: The ESP8266 (NodeMCU) will need to be programmed before the system can operate. Details on how to upload the firmware and configure the meter are covered in the next step.

Step 3: 🧩 Programming the ESP8266 (NodeMCU)

🧩 Step 3: Programming the ESP8266 (NodeMCU)

Before your meter can do anything useful, the ESP8266 needs to be programmed with the firmware. We’ll use the Arduino IDE along with the ESP8266 LittleFS Data Upload plugin to upload both the code and the web interface files stored in the data folder.

This step walks you through everything you need to prepare your NodeMCU.

🛠️ 1. Install the Arduino IDE (if you haven’t already)

Download from the official Arduino website and install it normally. You’ll also need to install the ESP8266 board package via the Boards Manager.

📦 2. Install the “ESP8266 LittleFS Data Upload” Plugin

This plugin adds a new menu option to the Arduino IDE that lets you upload files to the ESP8266’s internal flash filesystem (LittleFS).

You’ll need this because the meter’s web interface, CSS, JavaScript, and settings files all live inside the data folder.

Once installed, you’ll see a new menu item:

Tools → ESP8266 LittleFS Data Upload

(see screenshot.)

📁 3. Prepare the Project Folder

This is important:

✔️ All files must be inside a folder with the same name as the .ino file

Example: If your sketch is named:

Tims_Low_Ohms_Resistance_Meter.ino

Then all files must be inside:

Tims_Low_Ohms_Resistance_Meter/

This includes:

  1. The .ino file
  2. All .h and .cpp files
  3. The data/ folder (for the web interface)
  4. The renamed credentials file

Arduino will not compile correctly unless this rule is followed.

🔐 4. Rename “Credentials_(public).h” → “Credentials.h”

To protect my personal WiFi details I rename this file so that I don't accidently publish my personal details, the downloadable version includes:

Code

Credentials_(public).h

Before compiling, you must rename it to:

Code

Credentials.h

Then open it and update the WiFi settings:

  1. Choose AP mode or Station mode
  2. Enter your network name and password (if using Station mode)
  3. Or leave the defaults for Access Point mode

This file controls how your meter connects to WiFi.

The comments explains the differences.

🌐 5. Update Your Network Details

Inside Credentials.h, you’ll see:

#define LOCAL_SSID_STA "Your local network name"
#define LOCAL_PASSWORD_STA "Your local network password"

Replace these with your actual WiFi credentials if using Station mode.

If using Access Point mode, you can leave the defaults.

⬆️ 6. Upload the Firmware to the ESP8266

Once everything is in place:

  1. Select the correct board: Tools → Board → NodeMCU 1.0 (ESP-12E Module)
  2. Select the correct COM port
  3. Click the Upload button in the Arduino IDE
  4. If using an ESP8266 that has had another project on it, you may want to set it to erase all flash?
  5. Do not erase All Flash after uploading web file or they will get erased also.

(see screenshot.)

This uploads the main firmware.

📂 7. Upload the Web Interface Files (LittleFS)

After the firmware is uploaded:

  1. Go to Tools → ESP8266 LittleFS Data Upload
  2. Click it
  3. The contents of the data/ folder will be uploaded to the ESP8266’s flash filesystem

(see screenshot)

This step is essential — without it, the web interface won’t load.

📘 8. What’s Next?

I will just give some brief details of the code in the next steps.

Step 4: 🧩 the Main Firmware — Low_Ohms_Resistance_Meter.ino

🧩

This is the core firmware that runs the entire meter. It handles:

  1. 🧠 Reading the ADS1115 ADC
  2. 🔌 Talking to the OLED
  3. 🌐 Hosting the web interface
  4. 📡 Streaming live readings via WebSockets
  5. 🧮 Calculating resistance using Ohm’s Law

Before we dive into the code, here are the credits for the libraries used.

🙏 Library Credits

I always like to acknowledge the developers who make these libraries available:

  1. Wire.h — Arduino Team
  2. ADS1115.h — Baruch Even
  3. LittleFS.h — ESP8266 Arduino Core Team
  4. ESP8266WiFi.h — ESP8266 Arduino Core Team
  5. ESP8266WebServer.h — ESP8266 Arduino Core Team
  6. WebSocketsServer.h — Markus Sattler
  7. TIM_SSD1306_I2C.h — My own OLED driver
  8. Resource.h — My custom fonts

These libraries make the project possible, and giving credit is always good practice.

📘 What This File Does

Here’s a quick overview of the major functions in the .ino file and what each one does. This helps readers understand the flow before they scroll through the full code.

🔌 setupWiFi()

Configures the ESP8266 WiFi mode based on the settings in Credentials.h. Supports:

  1. Access Point
  2. Access Point with password
  3. Station mode (connect to your home network)

Prints connection info to Serial if enabled.

🛠️ setup()

Runs once at startup. It:

  1. Starts Serial (optional)
  2. Connects to WiFi
  3. Initializes I²C
  4. Configures the ADS1115 for differential measurements
  5. Mounts LittleFS
  6. Sets up HTTP routes
  7. Starts the WebSocket server
  8. Initializes the OLED
  9. Shows the splash screen

This is the “boot sequence” of the meter.

🔁 loop()

Runs continuously. It:

  1. Samples the DUT and shunt voltage every 200 ms
  2. Sends readings to the browser via WebSockets
  3. Updates the OLED
  4. Handles HTTP requests
  5. Keeps the WebSocket server alive

This is the “heartbeat” of the meter.

📡 readADS_Differential_A0_A1()

Reads the voltage across the DUT using ADS1115 differential mode.

📡 readADS_Differential_A2_A3()

Reads the voltage across the 1 Ω shunt to calculate current.

🧮 sendMeasurement()

This is the core measurement logic:

  1. Reads DUT voltage
  2. Reads shunt voltage
  3. Calculates current
  4. Calculates resistance
  5. Sends JSON data to the browser
  6. Updates the OLED

This is where the meter “thinks.”

🖥️ Set_OLED_Labels()

Draws the static labels on the OLED (title, units, etc.). Changes depending on whether calibration mode is active.

🖥️ Update_OLED()

Updates the live readings on the OLED:

  1. Current
  2. DUT voltage
  3. Resistance
  4. Calibration values (if enabled)

🎬 ShowSplash()

Displays the splash screen bitmap for 4 seconds at startup.

📄 Full .ino Code

/*
Low_Ohms_Resistance_Meter.ino
-----------------------------
ESP8266-based low-ohms resistance meter.
Uses an ADS1115 ADC to measure the voltage across a DUT
driven by an external constant-current front-end.

The ESP8266:
- Reads the differential voltage (TP2 - TP1) via ADS1115.
- Reads the current differential voltage across R1 via ADS1115.
- Calculates the resistance using Ohm's law.
- Serves a web page over HTTP using LittleFS.
- Streams live readings to the browser via WebSockets.

Dependencies:
index.html, styles.css, scripts.js (served via LittleFS)
-----------------------------
Created by Tim Jackson.1960, Dec 2025.

I = 100 mA constant current sourc for 1 Ω range (0.1 A)
I = 10 mA constant current source for 10 Ω range (0.01 A)

To reduse Font size, some Font Libraries have been modified.
In Large_Font_8x16, - = Space, char 45.
In Large_Font_8x16, / = *, char 47.

*/

#include <Wire.h> /* credit: Arduino Team. https://github.com/arduino/ArduinoCore-avr */
#include <ADS1115.h> /* credit: Baruch Even. https://github.com/baruch/ADS1115 */
#include <LittleFS.h> /* credit: ESP8266 Arduino Core Team. https://github.com/esp8266/Arduino/tree/master/libraries/LittleFS */
#include <ESP8266WiFi.h> /* credit: ESP8266 Arduino Core Team. https://github.com/esp8266/Arduino/tree/master/libraries/ESP8266WiFi */
#include <ESP8266WebServer.h> /* credit: ESP8266 Arduino Core Team. https://github.com/esp8266/Arduino/tree/master/libraries/ESP8266WebServer */
#include <WebSocketsServer.h> /* credit: Markus Sattler. https://github.com/Links2004/arduinoWebSockets */
#include "Credentials.h" /* My Local network login details (private) */
#include "TIM_SSD1306_I2C.h" /* My OLED Drive code. */
#include "Resource.h" /* My Fonts. */

/* GPIO Pins */
#define ADS_SCL 5 /* D1 = GPIO 5 */
#define ADS_SDA 4 /* D2 = GPIO 4 */

/* Server */
#define SAMPLE_INTERVAL_MS 200 /* Sampling interval for sending updates to the browser. */
ESP8266WebServer server(80);
WebSocketsServer webSocket(81);

/* 16 bit Analogue to Digital Converter */
ADS1115 ads;

/*
Current shunt (R1) actual measured value in ohms.
Measure with a precision meter and update here for best accuracy.

Calibration can be done by setting isCalibrateMode to true,
placing an amp meter in series with the DUT to measure actual current.
Use the current from the meter and the diplayed voltage and use ohms law to calculate R1 value:
*/
#define CURRENT_SHUNT_R1 0.99438f
bool isCalibrateMode = false; /* Calibration mode flag, set to true if calibrating R1. */

/* OLED */
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64

/*
The ADS1115 full-scale range (PGA) we choose determines
the conversion from raw counts to volts.

With ±0.256 V full scale:
LSB = 0.256 / 32768 ≈ 7.8125 µV per count.

NOTE:
The exact enum name for ±0.256 V may differ depending on the library.
For now we will keep ADS1115_PGA_TWO as used in your curve tracer
and treat the LSB constant as a calibration factor.
*/
const float ADC_LSB_VOLTS = 0.0000078125f; /* Start with 7.8125 µV, refine by calibration if needed */

/*
WebSocket JSON format:

{
"raw_v": <int>,
"raw_i": <int>,
"volts": <float>,
"ohms": <float>,
"range": <bool>
}

- "raw_v" = raw ADS1115 differential reading AIN0 <> AIN1.
- "raw_i" = raw ADS1115 differential reading AIN2 <> AIN3.
- "volts" = voltage across DUT in volts.
- "ohms" = calculated resistance in ohms.
- "range" = range switch state (true = 100 mA, false = 10 mA).

*/

/* Forward declarations */
void setupWiFi();
int16_t readADS_Differential_A0_A1();
int16_t readADS_Differential_A2_A3();
void sendMeasurement();

/* WiFi setup */
void setupWiFi() {

#ifdef ACCESS_POINT
WiFi.mode(WIFI_AP);
WiFi.softAP(ESP8266_SSID_AP);
#ifdef ENABLE_SERIAL
Serial.println("AP mode (no password)");
Serial.println(WiFi.softAPIP());
#endif
#endif /* ACCESS_POINT */

#ifdef ACCESS_POINT_PW
WiFi.mode(WIFI_AP);
WiFi.softAP(ESP8266_SSID_AP, ESP8266_PASSWORD_AP);
#ifdef ENABLE_SERIAL
Serial.println("AP mode (with password)");
Serial.println(WiFi.softAPIP());
#endif
#endif /* ACCESS_POINT_PW */

#ifdef ACCESS_POINT_STA
WiFi.mode(WIFI_STA);
WiFi.begin(LOCAL_SSID_STA, LOCAL_PASSWORD_STA);
#ifdef ENABLE_SERIAL
Serial.print("Connecting to Wi-Fi");
#endif
while (WiFi.status() != WL_CONNECTED) {
delay(500);
#ifdef ENABLE_SERIAL
Serial.print(".");
#endif
}
#ifdef ENABLE_SERIAL
Serial.println();
Serial.print("Connected! IP: ");
Serial.println(WiFi.localIP());
#endif
#endif /* ACCESS_POINT_STA */
}

/* Setup */
void setup() {

#ifdef ENABLE_SERIAL
Serial.begin(115200);
delay(200);
Serial.println();
Serial.println("Low Ohms Resistance Meter - Starting...");
#endif

setupWiFi();

/* I2C bus for ADS1115 */
Wire.begin(ADS_SDA, ADS_SCL);

/* ADS1115 setup */
ads.begin();
ads.set_data_rate(ADS1115_DATA_RATE_860_SPS);
ads.set_mode(ADS1115_MODE_SINGLE_SHOT);

/*
Configure for differential measurement between AIN0 <> AIN1 and AIN2 <> AIN3.

Wiring expectation:
- AIN0 (+) -> TP2 (TestPoint+ at DUT + side)
- AIN1 (-) -> TP1 (TestPoint- at DUT - side)

Wiring expectation:
- AIN2 (+) -> + at Emiter side of current shunt (1 ohm)
- AIN3 (-) -> - at switching side of current shunt (1 ohm)


- GND -> common 0 V of front-end + ESP8266
*/
ads.set_mux(ADS1115_MUX_DIFF_AIN0_AIN1);
ads.set_mux(ADS1115_MUX_DIFF_AIN2_AIN3);

/*
Set PGA (gain). We keep ADS1115_PGA_TWO as used in your curve tracer
and treat ADC_LSB_VOLTS as a calibration factor if needed.
*/
ads.set_pga(ADS1115_PGA_TWO);

/* File system for serving static content */
LittleFS.begin();

/* HTTP server routes */
server.serveStatic("/", LittleFS, "/index.html", "text/html");
server.serveStatic("/index.html", LittleFS, "/index.html", "text/html");
server.serveStatic("/styles.css", LittleFS, "/styles.css", "text/css");
server.serveStatic("/scripts.js", LittleFS, "/scripts.js", "text/javascript");
server.serveStatic("/favicon.ico", LittleFS, "/favicon.ico", "image/x-icon");
server.begin();

/* WebSocket server */
webSocket.begin();
webSocket.onEvent([](uint8_t client, WStype_t type, uint8_t* payload, size_t length) {
if (type == WStype_CONNECTED) {
#ifdef ENABLE_SERIAL
IPAddress ip = webSocket.remoteIP(client);
Serial.print("WebSocket client connected: ");
Serial.println(ip);
#endif
}
});

ssd1306_Init();
ShowSplash();
Set_OLED_Labels();


}

/* Main loop */
void loop() {

static unsigned long lastSampleMs = 0;
unsigned long now = millis();

/* Periodic measurement and broadcast */
if (now - lastSampleMs >= SAMPLE_INTERVAL_MS) {
lastSampleMs = now;
sendMeasurement();
}

server.handleClient();
webSocket.loop();
}

/* Read ADS1115 differential AIN0 - AIN1 */
int16_t readADS_Differential_A0_A1() {

ads.set_mux(ADS1115_MUX_DIFF_AIN0_AIN1);

if (ads.trigger_sample() != 0) {
/* Optional: handle error, return 0 as a safe default */
return 0;
}

while (ads.is_sample_in_progress()) {
delayMicroseconds(50);
}

return ads.read_sample(); /* raw int16_t result (signed) */
}

/* Read ADS1115 differential AIN2 - AIN3 (current shunt) */
int16_t readADS_Differential_A2_A3() {

ads.set_mux(ADS1115_MUX_DIFF_AIN2_AIN3);

if (ads.trigger_sample() != 0) {
/* Optional: handle error, return 0 as a safe default */
return 0;
}

while (ads.is_sample_in_progress()) {
delayMicroseconds(50);
}

return ads.read_sample();
}

/*
Take a volt measurement across DUT, convert volts/current to get ohms.
Take a volt measurement across 1 ohm shunt, convert volts/1 to get current.
Check range switch state.
send via WebSocket

*/
void sendMeasurement() {

/* Voltage across DUT */
int16_t raw_v = readADS_Differential_A0_A1();
float v_dut = ads.sample_to_float(raw_v);

/* Voltage across R1 (1 ohm shunt) */
int16_t raw_i = readADS_Differential_A2_A3();
float v_shunt = ads.sample_to_float(raw_i);

/* Current in amps (R1 = 1 ohm) I = V / 1Ω */
float current = v_shunt / CURRENT_SHUNT_R1;

/* Resistance calculation */
float ohms = (v_shunt > 0.0f) ? (v_dut / v_shunt) : -1.0f;

/* WebSocket JSON */
String json = "{";
json += "\"raw_v\":" + String(raw_v);
json += ",\"raw_i\":" + String(raw_i);
json += ",\"v_dut\":" + String(v_dut, 6);
json += ",\"v_shunt\":" + String(v_shunt, 6);
json += ",\"current\":" + String(current, 6);
json += ",\"ohms\":" + String(ohms, 6);
json += ",\"calibrate\":\"" + String(isCalibrateMode ? "true" : "false") + "\"";
json += "}";

webSocket.broadcastTXT(json);

/* Update OLED */
Update_OLED(v_dut, v_shunt, current, ohms);
}

/*
Set Display Labels

* 8 rows of text available (0-7), each 8 pixels high.

*/
void Set_OLED_Labels() {

ssd1306_FillScreen(Black);
if (isCalibrateMode) {
ssd1306_PrintStringCentered(0, "Calibration", White, Large_Font_8x16);
}
else {
ssd1306_PrintStringCentered(0, "OHMs/Meter", White, Large_Font_8x16);
ssd1306_PrintStringCentered(4, "Resistance", White, Small_Font_6x8);
}
ssd1306_UpdateScreen();
}

/* Update OLED display */
void Update_OLED(float v_dut, float v_shunt, float current, float ohms) {

char buf[20];
uint8_t used = 0;

if (isCalibrateMode) {
ssd1306_PrintString(0, 3, "R1:", White, Small_Font_6x8);

/* Show shunt voltage for calibration */
floatToStr(v_shunt, buf, 6);
used = ssd1306_PrintString(0, 4, buf, White, Small_Font_6x8);
ssd1306_PrintString(used, 4, " V ", White, Small_Font_6x8);
}
else {
/* Current in A */
floatToStr(current, buf, 4);
used = ssd1306_PrintString(0, 3, buf, White, Small_Font_6x8);
ssd1306_PrintString(used, 3, " A ", White, Small_Font_6x8);

/* Voltage across DUT */
floatToStr(v_dut, buf, 6);
used = ssd1306_PrintString(58, 3, buf, White, Small_Font_6x8);
ssd1306_PrintString(58 + used, 3, " V ", White, Small_Font_6x8);

/* Resistance */
if (ohms >= 0.0f) {
floatToStr(ohms, buf, 6);
used = ssd1306_PrintString(0, 5, buf, White, Large_Numbers_15x24);
ssd1306_PrintString(120, 5, "-", White, Large_Numbers_15x24);
}
else {
ssd1306_PrintString(0, 5, "/.//////", White, Large_Numbers_15x24);
}
}

ssd1306_UpdateScreen();
}

/* Show Splash Screen */
void ShowSplash() {

ssd1306_FillScreen(Black);
ssd1306_DrawBitmap(0, 0, SSD1306_Splash_128x64, 128, 64);
ssd1306_UpdateScreen();

delay(4000);
}

Step 5: 🧩 WiFi Settings — Credentials_(public).h

🧩

This file controls how your ESP8266 connects to WiFi. It lets you choose between Access Point mode, Access Point with password, or Station mode (connecting to your home network).

Because this file contains personal network details, the downloadable version is named:

Credentials_(public).h

Before compiling, you must rename it to:

Credentials.h

This prevents me from accidentally publishing my private WiFi credentials.

🔐 What This File Does

This file controls:

  1. 🌐 Which WiFi mode the meter uses
  2. 📶 The SSID and password for Access Point mode
  3. 🏠 Your home network name and password for Station mode
  4. 🖥️ Whether Serial debugging is enabled

Only one WiFi mode should be active at a time.

📡 WiFi Modes Explained

🟦 1. Station Mode (recommended)

The ESP8266 connects to your home WiFi network.

  1. You access the meter from any device already on your network
  2. No need to switch WiFi on your phone
  3. Best for workshop or home use

This is the default:

#define ACCESS_POINT_STA

🟩 2. Access Point Mode (open)

The ESP8266 creates its own WiFi network.

  1. No password
  2. Good for quick testing
  3. Works anywhere, even without internet

🟧 3. Access Point Mode (with password)

Same as above, but protected.

✏️ What You Need to Edit

If using Station mode, update these lines:

#define LOCAL_SSID_STA "Your local network name"
#define LOCAL_PASSWORD_STA "Your local network password"

Replace the text inside the quotes with your actual WiFi name and password.

If using Access Point mode, you can leave the defaults:

#define ESP8266_SSID_AP "Low Resistance Meter"
#define ESP8266_PASSWORD_AP "meter123"

📄 Full File (as included in the project)

#pragma once

/*
Type of connection?
"Access Point", "Access Point with password" or "Station"

"Access Point (AP) mode"
This is not connected to your local network, you connect to the Network of the ESP8266.
This means you can connect to it with your Moble Phone anywhare you are.
You change the WiFi your Phone is connected to, to the ESP8266 WiFi.
Then open browser to the IP of the ESP8266 control page. (we will be setting it to 192.168.50.11)
Have your serial monitor connected when you re-set the module to confirm correct IP Address.

"Access Point with Pasword (AP) mode"
This is same as above, but you need a password to connect to the WiFi. (it currently is: 12345678)

"Station (STA) mode"
In STA mode, the ESP8266 connects to an existing WiFi network created by your wireless router.
You will need to set the access credentials for your local network.
This is the recommended setting as it will enable you to use a web browser any device currently connected to your network.

Un-Comment which way you want to connect. (1 choice only)
*/

#define ACCESS_POINT_STA
// #define ACCESS_POINT
// #define ACCESS_POINT_PW

/*
These are made up for the websocket.
*/
#define ESP8266_SSID_AP "Low Resistance Meter"
#define ESP8266_PASSWORD_AP "meter123"

/*
The name of your local network.
The Password.

These are required if using the "Station (STA) mode".
*/
#define LOCAL_SSID_STA "Your local network name" /* Your local network name. */
#define LOCAL_PASSWORD_STA "Your local network password" /* Your local network password. */

#define ENABLE_SERIAL
#define DEBUG


Step 6: 🧩 Custom OLED Driver — TIM_SSD1306_I2C.h & TIM_SSD1306_I2C.cpp

🧩

The low‑ohms meter uses a 128×64 I²C OLED display, and instead of relying on a large, generic library, this project uses a lightweight custom driver written specifically for this meter.

Why?

  1. ⚡ Faster
  2. 🧼 Cleaner
  3. 🎛️ Only includes the features you actually need
  4. 🧮 Works perfectly with my custom fonts in Resource.h
  5. 📦 Keeps the firmware small and efficient

This step includes both the header (.h) and implementation (.cpp) files.

📘 What This Driver Does

The custom SSD1306 driver handles:

  1. Initializing the OLED over I²C
  2. Clearing and updating the screen
  3. Drawing text using your custom fonts
  4. Drawing bitmaps (like your splash screen)
  5. Printing centered text
  6. Printing at specific row/column positions
  7. Efficient screen updates without flicker

It’s designed to be simple, predictable, and easy to modify.

🧠 Key Methods (Quick Overview)

Here’s a brief description of the most important functions so readers know what they’re looking at:

ssd1306_Init()

Initializes the OLED display and configures it for 128×64 mode.

ssd1306_FillScreen(color)

Clears the screen to either Black or White.

ssd1306_UpdateScreen()

Pushes the internal buffer to the OLED. (Your driver uses a full framebuffer for clean updates.)

ssd1306_DrawBitmap(x, y, bitmap, w, h)

Draws a bitmap — used for the splash screen.

ssd1306_PrintString(x, row, text, color, font)

Prints text at a specific row and column.

ssd1306_PrintStringCentered(row, text, color, font)

Centers text horizontally — perfect for titles.

floatToStr()

Utility function for formatting floating‑point values for the OLED.

These functions are used throughout the .ino file to update the display cleanly and efficiently.

📄 TIM_SSD1306_I2C.h

// --- TIM_SSD1306_I2C.h ---
// Custom lightweight SSD1306 OLED driver for 128x64 I2C displays.
// Designed specifically for the Low Ohms Resistance Meter.

#pragma once
#include <Arduino.h>
#include <Wire.h>
#include "Resource.h"

#define Black 0
#define White 1

void ssd1306_Init();
void ssd1306_UpdateScreen();
void ssd1306_FillScreen(uint8_t color);
void ssd1306_DrawBitmap(int x, int y, const unsigned char* bitmap, int w, int h);

uint8_t ssd1306_PrintString(int x, int row, const char* str, uint8_t color, const uint8_t* font);
void ssd1306_PrintStringCentered(int row, const char* str, uint8_t color, const uint8_t* font);

void floatToStr(float value, char* buffer, uint8_t decimals);

📄 TIM_SSD1306_I2C.cpp

// --- TIM_SSD1306_I2C.cpp ---
// Implementation of the custom SSD1306 OLED driver.

#include "TIM_SSD1306_I2C.h"

static uint8_t buffer[1024]; // 128 x 64 / 8

static void ssd1306_WriteCommand(uint8_t cmd) {
Wire.beginTransmission(0x3C);
Wire.write(0x00);
Wire.write(cmd);
Wire.endTransmission();
}

void ssd1306_Init() {
Wire.begin();
delay(100);

ssd1306_WriteCommand(0xAE);
ssd1306_WriteCommand(0x20);
ssd1306_WriteCommand(0x00);
ssd1306_WriteCommand(0xB0);
ssd1306_WriteCommand(0xC8);
ssd1306_WriteCommand(0x00);
ssd1306_WriteCommand(0x10);
ssd1306_WriteCommand(0x40);
ssd1306_WriteCommand(0x81);
ssd1306_WriteCommand(0x7F);
ssd1306_WriteCommand(0xA1);
ssd1306_WriteCommand(0xA6);
ssd1306_WriteCommand(0xA8);
ssd1306_WriteCommand(0x3F);
ssd1306_WriteCommand(0xA4);
ssd1306_WriteCommand(0xD3);
ssd1306_WriteCommand(0x00);
ssd1306_WriteCommand(0xD5);
ssd1306_WriteCommand(0x80);
ssd1306_WriteCommand(0xD9);
ssd1306_WriteCommand(0xF1);
ssd1306_WriteCommand(0xDA);
ssd1306_WriteCommand(0x12);
ssd1306_WriteCommand(0xDB);
ssd1306_WriteCommand(0x40);
ssd1306_WriteCommand(0x8D);
ssd1306_WriteCommand(0x14);
ssd1306_WriteCommand(0xAF);

ssd1306_FillScreen(Black);
ssd1306_UpdateScreen();
}

void ssd1306_FillScreen(uint8_t color) {
memset(buffer, color ? 0xFF : 0x00, sizeof(buffer));
}

void ssd1306_UpdateScreen() {
for (uint8_t page = 0; page < 8; page++) {
ssd1306_WriteCommand(0xB0 + page);
ssd1306_WriteCommand(0x00);
ssd1306_WriteCommand(0x10);

Wire.beginTransmission(0x3C);
Wire.write(0x40);
for (uint8_t i = 0; i < 128; i++) {
Wire.write(buffer[page * 128 + i]);
}
Wire.endTransmission();
}
}

void ssd1306_DrawBitmap(int x, int y, const unsigned char* bitmap, int w, int h) {
for (int j = 0; j < h; j++) {
for (int i = 0; i < w; i++) {
int byteIndex = (j / 8) * w + i;
uint8_t bit = (bitmap[byteIndex] >> (j & 7)) & 1;
if (bit) {
buffer[(y + j) / 8 * 128 + (x + i)] |= (1 << ((y + j) & 7));
}
}
}
}

uint8_t ssd1306_PrintString(int x, int row, const char* str, uint8_t color, const uint8_t* font) {
uint8_t width = font[0];
uint8_t height = font[1];
uint8_t bytesPerChar = (height / 8) * width;

int cursor = x;

while (*str) {
char c = *str++;
const uint8_t* glyph = font + 2 + (c * bytesPerChar);

for (uint8_t col = 0; col < width; col++) {
uint8_t colData = glyph[col];
buffer[row * 128 + cursor + col] = color ? colData : ~colData;
}
cursor += width;
}

return cursor - x;
}

void ssd1306_PrintStringCentered(int row, const char* str, uint8_t color, const uint8_t* font) {
uint8_t width = font[0];
uint8_t len = strlen(str);
int x = (128 - (len * width)) / 2;
ssd1306_PrintString(x, row, str, color, font);
}

void floatToStr(float value, char* buffer, uint8_t decimals) {
dtostrf(value, 0, decimals, buffer);
}

Step 7: 🧩 Font & Bitmap Resources — Resource.h and Resource.cpp

🧩

The low‑ohms meter uses custom fonts and bitmap graphics to keep the OLED display clean, readable, and instrument‑grade. These resources live in two files:

  1. Resource.h — declarations and font metadata
  2. Resource.cpp — the actual font tables and bitmap data

These files work hand‑in‑hand with your custom OLED driver (TIM_SSD1306_I2C) to render:

  1. Titles
  2. Labels
  3. Numeric readouts
  4. The splash screen
  5. Calibration text

Because the SSD1306 is a pixel‑addressed display, these fonts are stored as raw byte arrays in PROGMEM for efficiency.

🎨 What These Files Do

Resource.h

This file contains:

  1. Font declarations
  2. Bitmap declarations
  3. External references used by the OLED driver

It tells the compiler what resources exist.

Resource.cpp

This file contains:

  1. The actual font data (byte arrays)
  2. The splash screen bitmap
  3. Any additional icons or symbols

It tells the compiler how the resources look.

🧠 Why Custom Fonts?

You chose to use custom fonts because:

  1. They render faster than large third‑party libraries
  2. They give you full control over appearance
  3. They reduce flash usage
  4. They allow you to modify characters (like your custom “-” and “/” replacements)
  5. They match the aesthetic of a proper bench instrument

This is especially important for the large numeric font, which is used for the resistance readout.

📘 Key Resources Included

These Resource files typically include:

  1. Small_Font_6x8 Used for labels, units, and small text.
  2. Large_Font_8x16 Used for titles and headers.
  3. Large_Numbers_15x24 Used for the main resistance display.
  4. SSD1306_Splash_128x64 The startup splash screen bitmap.

These are referenced throughout the .ino file and rendered by your OLED driver.

📄 Resource.h


/*
Resource.h

Fonts and bitmap resources for SSD1306.
Arduino/ESP8266 version.
*/

#ifndef __RESOURCE_H__
#define __RESOURCE_H__

#include <Arduino.h>

/*
128x64 Bitmap.
Used for splash screen (optional).
*/
extern const uint8_t SSD1306_Splash_128x64[];

/*
Large Numbers 15x24
Use '/' for space. Only digits 0–9.
*/
extern const uint8_t Large_Numbers_15x24[];

/*
Large Font 8x16
Use '/' for space.
*/
extern const uint8_t Large_Font_8x16[];

/*
Small Font 6x8
*/
extern const uint8_t Small_Font_6x8[];


#endif /* __RESOURCE_H__ */


📄 Resource.cpp


/*
Resource.cpp

Font and bitmap data stored in PROGMEM.
*/

#include "Resource.h"

/* 128x64 Splash Bitmap (from SSD1306_Buffer contents) */
const uint8_t SSD1306_Splash_128x64[] PROGMEM = {
0xFC, 0xFE, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x7F, 0x5F, 0x2F,
0x17, 0x9B, 0xDB, 0xBD, 0xFD, 0x9F, 0x0E, 0x2E, 0x06, 0x22, 0x72, 0x76, 0x3A, 0xB2, 0xA7, 0xA5,
0xE5, 0x4F, 0x0B, 0x1B, 0x17, 0x2F, 0x6F, 0xDF, 0xBF, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xCF, 0xEF, 0x0F, 0xEF, 0xCF, 0xFF, 0xFF, 0xFF, 0xBF, 0x2F, 0xFF, 0xFF,
0xFF, 0xFF, 0x3F, 0x3F, 0x3F, 0x3F, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xEF, 0xEF, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F, 0x6F, 0x6F, 0x6F, 0x0F, 0xFF, 0xFF, 0x0F,
0xEF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F, 0xEF, 0xEF, 0xEF, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0x07, 0xF3, 0x1B, 0xCB, 0xEB, 0xEB, 0xCB, 0xDB, 0xF3, 0x07, 0xFF, 0xFE, 0xFC,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F, 0x07, 0x38, 0x5E, 0x4F,
0x0F, 0x47, 0x47, 0x03, 0x13, 0x79, 0x80, 0xC4, 0xFE, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFE, 0xFE, 0xFE, 0xF8, 0xF4, 0x8C, 0x70, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF7, 0xF0, 0xF7, 0xF7,
0xFF, 0xFF, 0xF0, 0xFF, 0xF0, 0xFF, 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF9, 0xF9, 0xFF, 0xFF,
0xFF, 0xF7, 0xF7, 0xF0, 0xF7, 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0xF0,
0xF6, 0xF6, 0xF6, 0xF0, 0xFF, 0xFF, 0xFF, 0xF0, 0xF7, 0xF7, 0xF7, 0xF0, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xF0, 0xE7, 0xEC, 0xE9, 0xEB, 0xEB, 0xE9, 0xED, 0xE7, 0xF0, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1C, 0x00, 0x20, 0x04, 0x00,
0x00, 0x00, 0xC0, 0x60, 0x80, 0xF0, 0xF3, 0xF3, 0xF7, 0xE7, 0xEF, 0xCF, 0xCF, 0x07, 0x0B, 0x0B,
0xCB, 0x0B, 0x87, 0xC7, 0x1F, 0x0F, 0x3F, 0x1F, 0x1F, 0x00, 0x80, 0x7F, 0x7F, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x7F, 0x3F, 0x7F, 0xDF, 0x1F, 0x1C, 0xE0, 0xFA, 0x03,
0x30, 0x60, 0x03, 0xFC, 0x01, 0x0F, 0x8F, 0x3F, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0xFC, 0xFD,
0xFC, 0xFC, 0x7D, 0x3D, 0xBE, 0x1F, 0x18, 0x03, 0xC3, 0xF3, 0xF8, 0xF8, 0xFC, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xEB, 0xCD, 0x1D, 0x3B, 0x7B, 0xFB, 0xF6, 0xF4, 0xE1, 0xEE, 0xE3, 0x0E, 0xF8, 0xC1, 0x07, 0x18,
0x4C, 0xE0, 0xE2, 0x08, 0x83, 0x00, 0x40, 0x00, 0x80, 0xB0, 0x01, 0x04, 0x50, 0xD0, 0x80, 0x48,
0x42, 0xC2, 0x24, 0x00, 0x62, 0xF8, 0xFC, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0x07, 0x94, 0x31, 0xE8, 0xE9, 0xDD, 0xDD, 0xBD, 0x3B, 0x77, 0xF4, 0xF3, 0xEF, 0x2F, 0xCC,
0xD0, 0x89, 0xE3, 0xC3, 0x1D, 0x45, 0x25, 0x10, 0xE0, 0xF9, 0xFE, 0x7C, 0x44, 0x34, 0xCC, 0x8C,
0xF4, 0xF4, 0xF8, 0xF8, 0x07, 0x3F, 0x33, 0x07, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0x0F, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xCF, 0xCF, 0xCF, 0x0F, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xCF, 0xCF, 0xCF, 0x0F, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xBF, 0x62, 0xF3, 0xC6, 0x1C, 0xB1, 0x87, 0x9F, 0x31, 0xFE, 0x38, 0xE1, 0x0F, 0xFF, 0xF8, 0xF3,
0xC7, 0x7F, 0xFF, 0xFF, 0xFC, 0xC0, 0xBC, 0x80, 0xFB, 0x03, 0xFF, 0xFF, 0x36, 0x00, 0xE1, 0xF9,
0x19, 0xF1, 0x7F, 0xC3, 0xC8, 0x1E, 0x03, 0xF0, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0xE7, 0xE7, 0xE7, 0xE7, 0xE7, 0xE7,
0xE7, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x03, 0x01, 0x98, 0x9C, 0x9C, 0x9C, 0x9C, 0x9C, 0x98, 0x81,
0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0x03, 0x01, 0xF8, 0xFC, 0xFC, 0xFC, 0xFC, 0xFC, 0xF8, 0x01, 0x03, 0xFF, 0xFF, 0xFF,
0x3F, 0x7E, 0xFC, 0xFB, 0xF7, 0xCE, 0xE1, 0xFF, 0xE0, 0xFC, 0xF8, 0xE7, 0xCC, 0xC7, 0xFF, 0xFF,
0xFF, 0xF0, 0xEF, 0xDF, 0xFF, 0xBF, 0xBF, 0xFF, 0xC7, 0xC6, 0xE7, 0xC8, 0xE0, 0xFC, 0xFF, 0xE1,
0xCE, 0xE3, 0xFC, 0xFF, 0xFF, 0xC0, 0xFC, 0xFC, 0xFC, 0xC1, 0x97, 0xDF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xF0, 0xF0, 0xFF, 0xFF, 0xFF, 0xFC, 0xF8, 0xF1, 0xF3, 0xF3, 0xF3, 0xF3, 0xF3, 0xF3, 0xF3,
0xF3, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF3, 0xF3, 0xF3, 0xF0, 0xF0, 0xF3, 0xF3, 0xF3, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF3, 0xF3, 0xF3, 0xF0, 0xF0, 0xF3, 0xF3, 0xF3, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFC, 0xF8, 0xF1, 0xF3, 0xF3, 0xF3, 0xF3, 0xF3, 0xF1, 0xF8, 0xFC, 0xFF, 0x7F, 0x3F
};

/* Large Numbers 15x24 */
const uint8_t Large_Numbers_15x24[] PROGMEM = {
0x00, /* 0x00 = fixed font type. */
0x0F, /* 0x0F = 15 - font Width in pixels. */
0x18, /* 0x18 = 24 - font Height in pixels. */
0x2D, /* 0x2D = 45 - first ASCII character number in the font. */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* - = Space (45) */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* - = Space (45) */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* - = Space (45) */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* . (46) */
0x00, 0x00, 0x00, 0x00, 0x3C, 0x7E, 0x7E, 0x7E, 0x7E, 0x3C, 0x00, 0x00, 0x00, 0x00, 0x00, /* . (46) */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* . (46) */
0x00, 0x40, 0x80, 0x80, 0x00, 0x00, 0xE0, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x40, 0x00, 0x00, /* / = * (47) */
0x00, 0x18, 0x99, 0xDB, 0xFF, 0x7E, 0xFF, 0xFF, 0x7E, 0xFF, 0xDB, 0x99, 0x18, 0x18, 0x00, /* / = * (47) */
0x00, 0x02, 0x01, 0x01, 0x00, 0x00, 0x07, 0x07, 0x00, 0x00, 0x01, 0x01, 0x02, 0x00, 0x00, /* / = * (47) */
0x00, 0x00, 0xF8, 0xFE, 0xFF, 0x1F, 0x0F, 0x07, 0x07, 0x0F, 0x1F, 0xFF, 0xFE, 0xFC, 0x00, /* 0 */
0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x00, /* 0 */
0x00, 0x00, 0x1F, 0x7F, 0xFF, 0xF8, 0xF0, 0xE0, 0xE0, 0xF0, 0xF8, 0xFF, 0x7F, 0x3F, 0x00, /* 0 */
0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x0E, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, /* 1 */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, /* 1 */
0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0xE0, 0xFF, 0xFF, 0xFF, 0xE0, 0xE0, 0x00, 0x00, 0x00, /* 1 */
0x00, 0x00, 0x3C, 0x3E, 0x3F, 0x0F, 0x07, 0x07, 0x07, 0x07, 0x0F, 0xFF, 0xFE, 0xFC, 0x00, /* 2 */
0x00, 0x00, 0xE0, 0xF0, 0xF8, 0x78, 0x38, 0x38, 0x38, 0x38, 0x3C, 0x3F, 0x1F, 0x0F, 0x00, /* 2 */
0x00, 0x00, 0x7F, 0x7F, 0x7F, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x78, 0x78, 0x78, 0x00, /* 2 */
0x00, 0x00, 0x3C, 0x3E, 0x3F, 0x0F, 0x07, 0x07, 0x07, 0x07, 0x0F, 0xFF, 0xFE, 0xFC, 0x00, /* 3 */
0x00, 0x00, 0x00, 0x04, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x1F, 0xFF, 0xFF, 0xF3, 0x00, /* 3 */
0x00, 0x00, 0x1E, 0x3E, 0x7E, 0x78, 0x70, 0x70, 0x70, 0x70, 0x78, 0x7F, 0x3F, 0x1F, 0x00, /* 3 */
0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* 4 */
0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x80, 0x80, 0x80, 0x80, 0xFC, 0xFC, 0xFC, 0x80, 0x80, 0x00, /* 4 */
0x00, 0x00, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0xFF, 0xFF, 0xFF, 0x03, 0x03, 0x00, /* 4 */
0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x0F, 0x0F, 0x0F, 0x00, /* 5 */
0x00, 0x00, 0x1F, 0x1F, 0x1F, 0x0F, 0x07, 0x07, 0x07, 0x07, 0x0F, 0xFF, 0xFE, 0xFC, 0x00, /* 5 */
0x00, 0x00, 0x38, 0x78, 0xF8, 0xF0, 0xE0, 0xE0, 0xE0, 0xE0, 0xF0, 0xFF, 0x7F, 0x3F, 0x00, /* 5 */
0x00, 0x00, 0xFC, 0xFE, 0xFF, 0x0F, 0x07, 0x07, 0x07, 0x07, 0x0F, 0x3F, 0x3E, 0x3C, 0x00, /* 6 */
0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x3C, 0x1C, 0x1C, 0x1C, 0x1C, 0x3C, 0xFC, 0xF8, 0xF0, 0x00, /* 6 */
0x00, 0x00, 0x3F, 0x7F, 0xFF, 0xF0, 0xE0, 0xE0, 0xE0, 0xE0, 0xF0, 0xFF, 0x7F, 0x3F, 0x00, /* 6 */
0x00, 0x00, 0x3F, 0x3F, 0x3F, 0x07, 0x07, 0x07, 0x07, 0x07, 0x87, 0xFF, 0xFF, 0xFF, 0x00, /* 7 */
0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xE0, 0xF0, 0xFC, 0x7E, 0x1F, 0x0F, 0x03, 0x01, 0x00, /* 7 */
0x00, 0x00, 0x00, 0xFC, 0xFF, 0xFF, 0x07, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* 7 */
0x00, 0x00, 0xFC, 0xFE, 0xFF, 0x0F, 0x07, 0x07, 0x07, 0x07, 0x0F, 0xFF, 0xFE, 0xFC, 0x00, /* 8 */
0x00, 0x00, 0xE3, 0xF7, 0xFF, 0x3E, 0x1C, 0x1C, 0x1C, 0x1C, 0x3E, 0xFF, 0xF7, 0xE3, 0x00, /* 8 */
0x00, 0x00, 0x3F, 0x7F, 0xFF, 0xF0, 0xE0, 0xE0, 0xE0, 0xE0, 0xF0, 0xFF, 0x7F, 0x3F, 0x00, /* 8 */
0x00, 0x00, 0xFC, 0xFE, 0xFF, 0x0F, 0x07, 0x07, 0x07, 0x07, 0x0F, 0xFF, 0xFE, 0xFC, 0x00, /* 9 */
0x00, 0x00, 0x0F, 0x1F, 0x3F, 0x3C, 0x38, 0x38, 0x38, 0x38, 0x3C, 0xFF, 0xFF, 0xFF, 0x00, /* 9 */
0x00, 0x00, 0x3C, 0x7C, 0xFC, 0xF0, 0xE0, 0xE0, 0xE0, 0xE0, 0xF0, 0xFF, 0x7F, 0x3F, 0x00, /* 9 */
0x00 /* End of font */
};

/* Large Font 8x16 */
const uint8_t Large_Font_8x16[] PROGMEM = {
0x00, /* 0x00 means fixed font type - the only supported by the library */
0x08, /* 0x08 = 8 - font width in pixels */
0x10, /* 0x10 = 16 - font height in pixels */
0x2E, /* 0x2E = 46 - first ASCII character number in the font */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x38, 0x28, 0x38, 0x00, 0x00, 0x00, /* . */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* /=space 47 */
0x00, 0xF8, 0x04, 0x04, 0x04, 0x04, 0x04, 0xF8, 0x00, 0x1F, 0x20, 0x20, 0x20, 0x20, 0x20, 0x1F, /* 0 */
0x00, 0x00, 0x00, 0x08, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x3F, 0x20, 0x00, 0x00, /* 1 */
0x00, 0x78, 0x4C, 0x04, 0x04, 0x04, 0xCC, 0x78, 0x00, 0x30, 0x38, 0x24, 0x22, 0x21, 0x20, 0x30, /* 2 */
0x00, 0x38, 0x2C, 0x04, 0x84, 0x84, 0xCC, 0x78, 0x00, 0x1E, 0x32, 0x20, 0x20, 0x20, 0x31, 0x1F, /* 3 */
0x00, 0xFC, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x07, 0x04, 0x04, 0x3F, 0x04, 0x04, 0x00, /* 4 */
0x00, 0x7C, 0x44, 0x44, 0x44, 0x44, 0xC4, 0x8C, 0x00, 0x1E, 0x32, 0x20, 0x20, 0x20, 0x30, 0x1F, /* 5 */
0x00, 0xF8, 0xCC, 0x84, 0x84, 0x84, 0x8C, 0x18, 0x00, 0x1F, 0x31, 0x20, 0x20, 0x20, 0x31, 0x1F, /* 6 */
0x00, 0x0C, 0x04, 0x04, 0x04, 0xC4, 0x74, 0x1C, 0x00, 0x00, 0x30, 0x1C, 0x07, 0x01, 0x00, 0x00, /* 7 */
0x00, 0x78, 0xCC, 0x84, 0x84, 0x84, 0xCC, 0x78, 0x00, 0x1F, 0x31, 0x20, 0x20, 0x20, 0x31, 0x1F, /* 8 */
0x00, 0xF8, 0x8C, 0x04, 0x04, 0x04, 0x8C, 0xF8, 0x00, 0x10, 0x31, 0x21, 0x21, 0x21, 0x33, 0x1F, /* 9 */
0x00, 0x00, 0x00, 0x70, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1C, 0x1C, 0x00, 0x00, 0x00, /* : */
0x00, 0x00, 0x00, 0x70, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x1C, 0x0C, 0x00, 0x00, 0x00, /* ; */
0x00, 0x00, 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x00, 0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, /* < */
0x00, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, /* = */
0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x00, 0x00, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01, 0x00, 0x00, /* > */
0x00, 0x78, 0x4C, 0x04, 0x04, 0x04, 0x8C, 0x78, 0x00, 0x00, 0x00, 0x30, 0x36, 0x01, 0x00, 0x00, /* ? */
0x00, 0xF8, 0x0C, 0xE4, 0x34, 0xE4, 0x0C, 0xF8, 0x00, 0x1F, 0x30, 0x27, 0x2C, 0x27, 0x24, 0x37, /* @ */
0x00, 0xE0, 0x38, 0x0C, 0x04, 0x0C, 0x38, 0xE0, 0x00, 0x3F, 0x01, 0x01, 0x01, 0x01, 0x01, 0x3F, /* A */
0x00, 0xFC, 0x84, 0x84, 0x84, 0xCC, 0xF8, 0x00, 0x00, 0x3F, 0x20, 0x20, 0x20, 0x20, 0x31, 0x1F, /* B */
0xFE, 0x03, 0x01, 0x01, 0x01, 0x03, 0x06, 0x00, 0x3F, 0x60, 0x40, 0x40, 0x40, 0x60, 0x30, 0x00, /* C */
0x00, 0xFC, 0x04, 0x04, 0x04, 0x0C, 0x18, 0xF0, 0x00, 0x3F, 0x20, 0x20, 0x20, 0x30, 0x18, 0x0F, /* D */
0x00, 0xFC, 0x84, 0x84, 0x84, 0x84, 0x04, 0x04, 0x00, 0x3F, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, /* E */
0x00, 0xFC, 0x84, 0x84, 0x84, 0x84, 0x04, 0x04, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* F */
0x00, 0xF8, 0x0C, 0x04, 0x04, 0x04, 0x0C, 0x18, 0x00, 0x1F, 0x30, 0x20, 0x20, 0x21, 0x31, 0x1F, /* G */
0xFF, 0x80, 0x80, 0x80, 0x80, 0x80, 0xFF, 0x00, 0x7F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7F, 0x00, /* H */
0x00, 0x00, 0x00, 0x04, 0xFC, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x3F, 0x20, 0x00, 0x00, /* I */
0x00, 0x04, 0x04, 0x04, 0xFC, 0x04, 0x04, 0x04, 0x00, 0x18, 0x20, 0x20, 0x1F, 0x00, 0x00, 0x00, /* J */
0x00, 0xFC, 0x00, 0x00, 0x80, 0x60, 0x18, 0x04, 0x00, 0x3F, 0x01, 0x01, 0x03, 0x0C, 0x30, 0x00, /* K */
0x00, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3F, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, /* L */
0xFF, 0x0F, 0xFC, 0xC0, 0xFC, 0x0F, 0xFF, 0x00, 0x7F, 0x00, 0x00, 0x07, 0x00, 0x00, 0x7F, 0x00, /* M */
0x00, 0xFC, 0x1C, 0x70, 0xC0, 0x00, 0x00, 0xFC, 0x00, 0x3F, 0x00, 0x00, 0x01, 0x0F, 0x38, 0x3F, /* N */
0xFE, 0x03, 0x01, 0x01, 0x01, 0x03, 0xFE, 0x00, 0x3F, 0x60, 0x40, 0x40, 0x40, 0x60, 0x3F, 0x00, /* O */
0x00, 0xFC, 0x04, 0x04, 0x04, 0x04, 0x8C, 0xF8, 0x00, 0x3F, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, /* P */
0x00, 0xF8, 0x0C, 0x04, 0x04, 0x04, 0x0C, 0xF8, 0x00, 0x1F, 0x30, 0x20, 0x26, 0x28, 0x30, 0x3F, /* Q */
0x00, 0xFC, 0x04, 0x04, 0x04, 0x04, 0x8C, 0xF8, 0x00, 0x3F, 0x01, 0x01, 0x03, 0x0F, 0x39, 0x20, /* R */
0x00, 0x78, 0xCC, 0x84, 0x84, 0x04, 0x0C, 0x38, 0x00, 0x1C, 0x30, 0x20, 0x21, 0x21, 0x33, 0x1E, /* S */
0x00, 0x04, 0x04, 0x04, 0xFC, 0x04, 0x04, 0x04, 0x00, 0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, /* T */
0x00, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0x00, 0x1F, 0x30, 0x20, 0x20, 0x20, 0x30, 0x1F, /* U */
0x00, 0x1C, 0xE0, 0x00, 0x00, 0x00, 0xE0, 0x1C, 0x00, 0x00, 0x00, 0x07, 0x38, 0x07, 0x00, 0x00, /* V */
0x00, 0xFC, 0x00, 0xC0, 0x30, 0xC0, 0x00, 0xFC, 0x00, 0x3F, 0x1C, 0x03, 0x00, 0x03, 0x1C, 0x3F, /* W */
0x00, 0x04, 0x18, 0x60, 0x80, 0x60, 0x18, 0x04, 0x00, 0x20, 0x18, 0x06, 0x01, 0x06, 0x18, 0x20, /* X */
0x00, 0x0C, 0x30, 0xC0, 0x00, 0xC0, 0x30, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, /* Y */
0x00, 0x04, 0x04, 0x04, 0x84, 0x64, 0x14, 0x0C, 0x00, 0x30, 0x28, 0x26, 0x21, 0x20, 0x20, 0x20, /* Z */
0x00, 0xFC, 0x04, 0x04, 0x04, 0x0C, 0x00, 0x00, 0x00, 0x3F, 0x20, 0x20, 0x20, 0x30, 0x00, 0x00, /* [ */
0x00, 0x04, 0x18, 0x60, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x06, 0x18, 0x20, /* "\" */
0x00, 0x00, 0x00, 0x0C, 0x04, 0x04, 0x04, 0xFC, 0x00, 0x00, 0x00, 0x30, 0x20, 0x20, 0x20, 0x3F, /* ] */
0x00, 0x00, 0x10, 0x08, 0x04, 0x08, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* ^ */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, /* _ */
0x00, 0x20, 0x30, 0x1C, 0x0C, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* ` */
0x00, 0x80, 0x80, 0x80, 0x80, 0x80, 0x00, 0x00, 0x7F, 0x09, 0x08, 0x08, 0x08, 0x09, 0x7F, 0x00, /* a */
0x80, 0x80, 0x80, 0x80, 0x80, 0x00, 0x00, 0x00, 0x7F, 0x44, 0x44, 0x44, 0x44, 0x4B, 0x30, 0x00, /* b */
0x00, 0x80, 0xC0, 0x40, 0x40, 0x40, 0xC0, 0x80, 0x00, 0x1F, 0x30, 0x20, 0x20, 0x20, 0x30, 0x19, /* c */
0x00, 0x80, 0xC0, 0x40, 0x40, 0x40, 0xFC, 0x04, 0x00, 0x1F, 0x30, 0x20, 0x20, 0x20, 0x3F, 0x20, /* d */
0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x00, 0x7F, 0x44, 0x44, 0x44, 0x44, 0x40, 0x40, 0x00, /* e */
0x00, 0x00, 0x00, 0xF0, 0x18, 0x08, 0x08, 0x18, 0x00, 0x01, 0x21, 0x3F, 0x21, 0x01, 0x01, 0x00, /* f */
0x00, 0x80, 0xC0, 0x40, 0x40, 0x40, 0xC0, 0x80, 0x00, 0x27, 0x6C, 0x48, 0x48, 0x48, 0x6C, 0x3F, /* g */
0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x7F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x7F, 0x00, /* h */
0x00, 0x00, 0x80, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x7F, 0x40, 0x00, 0x00, 0x00, /* i */
0x00, 0x00, 0x80, 0x98, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x40, 0x7F, 0x00, 0x00, 0x00, 0x00, /* j */
0x00, 0x04, 0xFC, 0x00, 0x00, 0x80, 0x40, 0x40, 0x00, 0x20, 0x3F, 0x06, 0x09, 0x10, 0x20, 0x20, /* k */
0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7F, 0x40, 0x40, 0x40, 0x40, 0x40, 0x00, 0x00, /* l */
0x80, 0x80, 0x00, 0x00, 0x00, 0x80, 0x80, 0x00, 0x7F, 0x03, 0x0E, 0x38, 0x0E, 0x03, 0x7F, 0x00, /* m */
0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x7F, 0x01, 0x07, 0x0C, 0x38, 0x60, 0x7F, 0x00, /* n */
0x00, 0x00, 0x80, 0x80, 0x80, 0x00, 0x00, 0x00, 0x1E, 0x21, 0x40, 0x40, 0x40, 0x21, 0x1E, 0x00, /* o */
0x00, 0x00, 0xC0, 0x40, 0x40, 0x40, 0xC0, 0x80, 0x00, 0x40, 0x7F, 0x08, 0x08, 0x08, 0x0C, 0x07, /* p */
0x00, 0x00, 0x80, 0xC0, 0x40, 0x40, 0x40, 0xC0, 0x00, 0x00, 0x07, 0x0C, 0x08, 0x08, 0x48, 0x7F, /* q */
0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x00, 0x00, 0x7F, 0x04, 0x04, 0x0C, 0x14, 0x24, 0x43, 0x00, /* r */
0x00, 0x80, 0x80, 0x80, 0x80, 0x80, 0x00, 0x00, 0x23, 0x44, 0x44, 0x44, 0x44, 0x44, 0x39, 0x00, /* s */
0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x7F, 0x00, 0x00, 0x00, 0x00, /* t */
0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x40, 0x00, 0x1F, 0x30, 0x20, 0x20, 0x30, 0x1F, 0x30, /* u */
0x00, 0xC0, 0x40, 0x00, 0x00, 0x00, 0xC0, 0x40, 0x00, 0x00, 0x0F, 0x30, 0x30, 0x0F, 0x00, 0x00, /* v */
0x00, 0xC0, 0x40, 0x00, 0x00, 0x00, 0x40, 0xC0, 0x00, 0x00, 0x0F, 0x30, 0x0E, 0x30, 0x0F, 0x00, /* w */
0x00, 0x40, 0xC0, 0x00, 0x00, 0x00, 0xC0, 0x40, 0x00, 0x20, 0x30, 0x09, 0x06, 0x09, 0x30, 0x20, /* x */
0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x43, 0x46, 0x24, 0x1C, 0x0E, 0x03, /* y */
0x00, 0xC0, 0x40, 0x40, 0x40, 0x40, 0xC0, 0xC0, 0x00, 0x30, 0x38, 0x2C, 0x26, 0x23, 0x21, 0x30, /* z */
0x00, 0x06, 0x09, 0x09, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* { changed to degree. */
0x00/* End of font */
};

/* Small Font 6x8 */
const uint8_t Small_Font_6x8[] PROGMEM = {
0x00, /* 0x00 means fixed font type - the only supported by the library */
0x06, /* 0x06 = 6 - font width in pixels */
0x08, /* 0x08 = 8 - font height in pixels */
0x20, /* 0x20 = 32 - first ASCII character number in the font */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* Space 32 */
0x00, 0x00, 0x5F, 0x00, 0x00, 0x00, /* ! 33 */
0x00, 0x07, 0x00, 0x07, 0x00, 0x00, /* " 34 */
0x14, 0x7F, 0x14, 0x7F, 0x14, 0x00, /* # 35 */
0x24, 0x2A, 0x7F, 0x2A, 0x12, 0x00, /* $ 36 */
0x62, 0x15, 0x2A, 0x54, 0x23, 0x00, /* % 37 */
0x3A, 0x45, 0x65, 0x3A, 0x50, 0x00, /* & 38 */
0x00, 0x04, 0x02, 0x01, 0x00, 0x00, /* ' 39 */
0x1C, 0x22, 0x41, 0x41, 0x00, 0x00, /* ( 40 */
0x00, 0x41, 0x41, 0x22, 0x1C, 0x00, /* ) 41 */
0x2A, 0x1C, 0x3E, 0x1C, 0x2A, 0x00, /* * 42 */
0x08, 0x08, 0x3E, 0x08, 0x08, 0x00, /* + 43 */
0x00, 0x00, 0x04, 0x03, 0x00, 0x00, /* ' 44 */
0x08, 0x08, 0x08, 0x08, 0x08, 0x00, /* - 45 */
0x00, 0x60, 0x60, 0x00, 0x00, 0x00, /* . 46 */
0x40, 0x30, 0x08, 0x06, 0x01, 0x00, /* / 47 */
0x3E, 0x41, 0x41, 0x41, 0x3E, 0x00, /* 0 48 */
0x00, 0x42, 0x7F, 0x40, 0x00, 0x00, /* 1 49 */
0x76, 0x51, 0x49, 0x49, 0x66, 0x00, /* 2 50 */
0x22, 0x41, 0x49, 0x49, 0x36, 0x00, /* 3 51 */
0x1F, 0x10, 0x7C, 0x10, 0x10, 0x00, /* 4 52 */
0x27, 0x45, 0x45, 0x45, 0x39, 0x00, /* 5 53 */
0x3E, 0x49, 0x49, 0x49, 0x32, 0x00, /* 6 54 */
0x63, 0x31, 0x19, 0x0D, 0x07, 0x00, /* 7 55 */
0x36, 0x49, 0x49, 0x49, 0x36, 0x00, /* 8 56 */
0x26, 0x49, 0x49, 0x49, 0x3E, 0x00, /* 9 57 */
0x00, 0x36, 0x36, 0x00, 0x00, 0x00, /* : 58 */
0x40, 0x36, 0x16, 0x00, 0x00, 0x00, /* ; 59 */
0x08, 0x1C, 0x36, 0x22, 0x41, 0x00, /* < 60 */
0x14, 0x14, 0x14, 0x14, 0x14, 0x00, /* = 61 */
0x41, 0x22, 0x36, 0x1C, 0x08, 0x00, /* > 62 */
0x06, 0x01, 0x51, 0x09, 0x06, 0x00, /* ? 63 */
0x3E, 0x41, 0x5D, 0x55, 0x1E, 0x00, /* @ 64 */
0x7E, 0x13, 0x11, 0x13, 0x7E, 0x00, /* A 65 */
0x7F, 0x49, 0x49, 0x49, 0x36, 0x00, /* B 66 */
0x3E, 0x41, 0x41, 0x41, 0x22, 0x00, /* C 67 */
0x7F, 0x41, 0x41, 0x41, 0x3E, 0x00, /* D 68 */
0x7F, 0x49, 0x49, 0x49, 0x41, 0x00, /* E 69 */
0x7F, 0x09, 0x09, 0x09, 0x01, 0x00, /* F 70 */
0x3E, 0x41, 0x41, 0x51, 0x32, 0x00, /* G 71 */
0x7F, 0x08, 0x08, 0x08, 0x7F, 0x00, /* H 72 */
0x00, 0x41, 0x7F, 0x41, 0x00, 0x00, /* I 73 */
0x21, 0x41, 0x3F, 0x01, 0x01, 0x00, /* J 74 */
0x7F, 0x18, 0x3C, 0x66, 0x43, 0x00, /* K 75 */
0x7F, 0x40, 0x40, 0x40, 0x40, 0x00, /* L 76 */
0x7F, 0x06, 0x3C, 0x06, 0x7F, 0x00, /* M 77 */
0x7F, 0x06, 0x1C, 0x30, 0x7F, 0x00, /* N 78 */
0x3E, 0x41, 0x41, 0x41, 0x3E, 0x00, /* O 79 */
0x7F, 0x09, 0x09, 0x09, 0x06, 0x00, /* P 80 */
0x3E, 0x41, 0x51, 0x21, 0x5E, 0x00, /* Q 81 */
0x7F, 0x09, 0x19, 0x79, 0x46, 0x00, /* R 82 */
0x26, 0x49, 0x49, 0x49, 0x32, 0x00, /* S 83 */
0x01, 0x01, 0x7F, 0x01, 0x01, 0x00, /* T 84 */
0x3F, 0x40, 0x40, 0x40, 0x3F, 0x00, /* U 85 */
0x07, 0x1C, 0x70, 0x1C, 0x07, 0x00, /* V 86 */
0x1F, 0x70, 0x1C, 0x70, 0x1F, 0x00, /* W 87 */
0x63, 0x36, 0x08, 0x36, 0x63, 0x00, /* X 88 */
0x03, 0x0E, 0x78, 0x0E, 0x03, 0x00, /* Y 89 */
0x61, 0x51, 0x49, 0x45, 0x43, 0x00, /* Z 90 */
0x7F, 0x41, 0x41, 0x00, 0x00, 0x00, /* [ 91 */
0x01, 0x06, 0x08, 0x30, 0x40, 0x00, /* \ 92 */
0x00, 0x00, 0x41, 0x41, 0x7F, 0x00, /* ] 93 */
0x04, 0x02, 0x01, 0x02, 0x04, 0x00, /* ^ 94 */
0x80, 0x80, 0x80, 0x80, 0x80, 0x80, /* _ 95 */
0x00, 0x01, 0x01, 0x03, 0x00, 0x00, /* ¬ 96 */
0x78, 0x14, 0x14, 0x14, 0x78, 0x00, /* a 97 */
0x7C, 0x54, 0x54, 0x54, 0x28, 0x00, /* b 98 */
0x38, 0x44, 0x44, 0x44, 0x28, 0x00, /* c 99 */
0x7C, 0x44, 0x44, 0x44, 0x38, 0x00, /* d 100 */
0x7C, 0x54, 0x54, 0x44, 0x44, 0x00, /* e 101 */
0x7C, 0x14, 0x14, 0x04, 0x04, 0x00, /* f 102 */
0x38, 0x44, 0x44, 0x54, 0x74, 0x00, /* g 103 */
0x7C, 0x10, 0x10, 0x10, 0x7C, 0x00, /* h 104 */
0x00, 0x44, 0x7C, 0x44, 0x00, 0x00, /* i 105 */
0x24, 0x44, 0x3C, 0x04, 0x00, 0x00, /* j 106 */
0x7C, 0x20, 0x10, 0x68, 0x04, 0x00, /* k 107 */
0x7C, 0x40, 0x40, 0x40, 0x40, 0x00, /* l 108 */
0x7C, 0x0C, 0x30, 0x0C, 0x7C, 0x00, /* m 109 */
0x7C, 0x0C, 0x10, 0x60, 0x7C, 0x00, /* n 110 */
0x38, 0x44, 0x44, 0x44, 0x38, 0x00, /* o 111 */
0x7C, 0x14, 0x14, 0x14, 0x08, 0x00, /* p 112 */
0x38, 0x44, 0x54, 0x24, 0x58, 0x00, /* q 113 */
0x7C, 0x14, 0x14, 0x34, 0x48, 0x00, /* r 114 */
0x48, 0x54, 0x54, 0x54, 0x24, 0x00, /* s 115 */
0x04, 0x04, 0x7C, 0x04, 0x04, 0x00, /* t 116 */
0x3C, 0x40, 0x40, 0x40, 0x3C, 0x00, /* u 117 */
0x0C, 0x38, 0x60, 0x38, 0x0C, 0x00, /* v 118 */
0x1C, 0x70, 0x18, 0x70, 0x1C, 0x00, /* w 119 */
0x44, 0x28, 0x10, 0x28, 0x44, 0x00, /* x 120 */
0x04, 0x08, 0x70, 0x08, 0x04, 0x00, /* y 121 */
0x44, 0x64, 0x54, 0x4C, 0x44, 0x00, /* z 122 */
0x08, 0x2A, 0x5D, 0x41, 0x00, 0x00, /* { 123 */
0x00, 0x00, 0x7F, 0x00, 0x00, 0x00, /* | 124 */
0x00, 0x41, 0x5D, 0x2A, 0x08, 0x00, /* } 125 */
0x08, 0x04, 0x08, 0x10, 0x08, 0x00, /* ~ 126 */
0x00/* End of font */
};

📝 Notes for Makers

  1. These fonts are binary data, so they must be copied exactly as provided.
  2. They live in PROGMEM to save RAM.
  3. You can modify or add fonts if you want to customize the look of the meter.
  4. The .ino file and OLED driver both depend on these resources being present.

Step 8: 🧩 Web Interface Files (data Folder)

🧩

The low‑ohms meter includes a built‑in web interface served directly from the ESP8266 using LittleFS. These files live inside the data/ folder of the project and must be uploaded using the ESP8266 LittleFS Data Upload tool.

Because Instructables does not allow uploading these file types directly, you’ll paste the contents of each file listed below with the exception of "favicon.ico" this needs to be created using an Icon editor and image.

The data folder contains:

  1. favicon.ico
  2. index.html
  3. scripts.js
  4. styles.css

Let’s go through each one.

🌐 What the Web Interface Does

The browser interface provides:

  1. Live resistance readings
  2. Live voltage and current values
  3. Auto‑updating display via WebSockets
  4. A clean, responsive layout
  5. Calibration mode support

Everything is lightweight and optimized for the ESP8266.

📁 1. favicon.ico

This is the small icon shown in the browser tab.

Because Instructables cannot host binary .ico files, you have two options:

✔️ Option A — Create it yourself

Use any icon editor (e.g., GIMP, RealFaviconGenerator, ICOConvert) and recreate the icon using the image below:

(Attached a resistor image)

✔️ Option B — Download it from GitHub

👉 Tim-s-Precision-4-Wire-Low-Resistance-Meter/README.md at main · Palingenesis/Tim-s-Precision-4-Wire-Low-Resistance-Meter

📄 2. index.html

This is the main webpage served by the ESP8266. It loads the CSS, JavaScript, and opens the WebSocket connection.

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Low Ohms Resistance Meter</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<h1 id="Title">Tim's Low-Ohms<br />
Resistance Meter
</h1>

<div id="meterContainer">

<div id="voltageBox" class="meter-section">
<div id="VoltageAcrossDUT_titleBox" class="meter-label">
<span id="dutLabel">Voltage Across DUT</span>
<span id="avgVoltage" class="avgValue">--.------</span>
<span id="avgVoltage_Tag" class="avgTag">AVG</span>
</div>
<div id="voltageDisplay" class="meter-value">
<span id="dutNumber" class="value-number">--.------</span>
<span class="value-tag volt-tag">V</span>
</div>
</div>

<div class="meter-section" id="currentBox">
<div id="Current_titleBox" class="meter-label">
Current
<span id="avgCurrent" class="avgValue">--.------</span>
<span id="avgCurrent_Tag" class="avgTag">AVG</span>
</div>
<div id="currentDisplay" class="meter-value">
<span id="currentNumber" class="value-number">--.------</span>
<span class="value-tag amp-tag">A</span>
</div>
</div>

<div id="ohmsBox" class="meter-section">
<div id="Ohms_titleBox" class="meter-label">
Ohms
<span id="avgOhms" class="avgValue">--.------</span>
<span id="avgOhms_Tag" class="avgTag">AVG</span>
</div>
<div id="ohmsDisplay" class="meter-value">
<span id="ohmsNumber" class="value-number">--.------</span>
<span class="value-tag ohm-tag">Ω</span>
</div>
</div>



<div id="graphBox" class="meter-section">
<div id="graphBoxTitleBox">
<div id="graphBoxTitle" class="meter-label">Resistance Graph</div>
<span id="deltaValue">--.------</span>
<span id="deltaTag" class="value-tag ohm-tag">Δ</span>
</div>
<div id="graphContainer" class="meter-value">
<canvas id="graphCanvas"></canvas>
</div>
</div>

</div>

<div id="statusBar">
<span id="statusText">Connecting to meter...</span>
</div>

<script src="/scripts.js"></script>
</body>

</html>

<footer id="footer">
Low-Ohms Resistance Meter UI &copy; 2025 Tim Jackson.1960<br>
Powered by open-source libraries:
<a href="https://github.com/baruch/ADS1115" target="_blank">ADS1115 by Baruch Even
</a>|
<a href="https://github.com/Links2004/arduinoWebSockets" target="_blank">WebSockets by Markus Sattler
</a>|
<a href="https://github.com/esp8266/Arduino/tree/master/libraries" target="_blank">ESP8266WiFi, ESP8266WebServer, LittleFS
</a>
</footer>

📄 3. scripts.js

This file handles:

  1. Opening the WebSocket
  2. Receiving live measurement data
  3. Updating the DOM
  4. Displaying resistance, voltage, current
  5. Handling calibration mode

/* WebSocket connection to ESP8266 */
let socket = null;

const voltageDisplay = document.getElementById("voltageDisplay");
const currentDisplay = document.getElementById("currentDisplay");
const ohmsDisplay = document.getElementById("ohmsDisplay");
const statusText = document.getElementById("statusText");
const dutLabel = document.getElementById("dutLabel");
const dutNumber = document.getElementById("dutNumber");

let maxPoints = 200; /* Number of samples stored (affects horizontal spacing) */
let graphXScale = 5.0; /* Horizontal scaling multiplier (you can tune this) */
let voltageHistory = [];
let currentHistory = [];
let resistanceHistory = [];
const canvas = document.getElementById("graphCanvas");
const ctx = canvas.getContext("2d");

const colourV = "#4da6ff"; /* blue */
const colourA = "#ff9933"; /* orange */
const colourR = "#ffff66"; /* yellow */

/* Connect WebSocket */
function connectWebSocket() {
const wsUrl = "ws://" + window.location.hostname + ":81";
socket = new WebSocket(wsUrl);

socket.onopen = () => {
statusText.textContent = "Connected";
statusText.style.color = "#0f0";
};

socket.onclose = () => {
statusText.textContent = "Disconnected - retrying...";
statusText.style.color = "#f80";
setTimeout(connectWebSocket, 2000);
};

socket.onerror = (err) => {
console.error("WebSocket error:", err);
statusText.textContent = "WebSocket error";
statusText.style.color = "#f00";
};

socket.onmessage = (event) => {

try {
const obj = JSON.parse(event.data); /* Expect: { raw, volts, ohms_x10, ohms_x100, range } */

if (obj.calibrate === "true") {
// Calibration mode
dutLabel.textContent = "CALIBRATION MODE";
dutLabel.classList.add("calibrate");

if (typeof obj.v_shunt === "number") {
dutNumber.textContent = obj.v_shunt.toFixed(6);
}
} else {
// Normal mode
dutLabel.textContent = "Voltage Across DUT";
dutLabel.classList.remove("calibrate");

if (typeof obj.v_dut === "number") {
dutNumber.textContent = obj.v_dut.toFixed(6);
}
}

if (typeof obj.current === "number") {
document.getElementById("currentNumber").textContent =
obj.current.toFixed(6);
}
if (typeof obj.ohms === "number" && obj.ohms >= 0) {
document.getElementById("ohmsNumber").textContent =
obj.ohms.toFixed(6);
} else {
document.getElementById("ohmsNumber").textContent = "--.------";
}

/* Update graph data */
if (typeof obj.v_dut === "number" && obj.v_dut >= 0) {
voltageHistory.push(obj.v_dut);
if (voltageHistory.length > maxPoints) voltageHistory.shift();
}
if (typeof obj.current === "number" && obj.current >= 0) {
currentHistory.push(obj.current);
if (currentHistory.length > maxPoints) currentHistory.shift();
}
if (typeof obj.ohms === "number" && obj.ohms >= 0) {
resistanceHistory.push(obj.ohms);
if (resistanceHistory.length > maxPoints) resistanceHistory.shift();
}
drawGraph();

let delta = computeDelta(resistanceHistory);
document.getElementById("deltaValue").textContent = delta.toFixed(6);

let avgV = computeAverage(voltageHistory);
let avgI = computeAverage(currentHistory);
let avgR = computeAverage(resistanceHistory);

document.getElementById("avgVoltage").textContent = avgV.toFixed(6);
document.getElementById("avgCurrent").textContent = avgI.toFixed(6);
document.getElementById("avgOhms").textContent = avgR.toFixed(6);

statusText.textContent = "Live data";
statusText.style.color = "#0f0";

} catch (e) {
console.error("Failed to parse message:", e);
statusText.textContent = "Data parse error";
statusText.style.color = "#f00";
}
};
}

/* Utility function to compute delta of an array */
function computeDelta(arr) {
if (arr.length < 2) return 0;
let min = Math.min(...arr);
let max = Math.max(...arr);
return max - min;
}

/* Utility function to compute average of last N samples in an array */
function computeAverage(arr, count = 100) {
if (arr.length === 0) return 0;

let slice = arr.slice(-count); // last N samples
let sum = slice.reduce((a, b) => a + b, 0);
return sum / slice.length;
}


/* Start connection when page loads */
window.addEventListener("load", () => {
connectWebSocket();
});

/* Graph drawing logic */
function drawGraph() {
ctx.clearRect(0, 0, canvas.width, canvas.height);

const h = canvas.height;

// Anchor positions
const yV = h * 0.20; // Voltage
const yA = h * 0.50; // Current
const yR = h * 0.80; // Resistance

drawTrace(voltageHistory, "#4da6ff", yV);
drawTrace(currentHistory, "#ff9933", yA);
drawTrace(resistanceHistory, "#ffff66", yR);
}
function drawTrace(data, colour, anchorY) {
if (data.length < 2) return;

ctx.strokeStyle = colour;
ctx.lineWidth = 1;
ctx.beginPath();

const stepX = graphXScale; // fixed pixel spacing per sample

const maxVal = Math.max(...data);
const minVal = Math.min(...data);
const range = maxVal - minVal || 1;

for (let i = 0; i < data.length; i++) {
const x = i * stepX;

// If trace goes off the right edge, shift history
if (x > canvas.width) {
data.shift();
continue;
}

const amplitude = ((data[i] - minVal) / range - 0.5) * 40;
const y = anchorY - amplitude;

if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}

ctx.stroke();
}

📄 4. styles.css

This file controls:

  1. Layout
  2. Fonts
  3. Colors
  4. Spacing
  5. Responsive behavior
body {
font-family: sans-serif;
background-color: #000;
text-align: center;
margin: 20px;
color: #eee;
}

#Title {
color: #0f0;
margin-bottom: 20px;
text-shadow: 0 0 8px #0f0;
}

/* Main meter container as a 2×3 grid */
#meterContainer {
width: 750px;
margin: 0 auto;
padding: 20px;
border-radius: 16px;
background: radial-gradient(circle at top, #222 0%, #000 60%, #000 100%);
box-shadow: 0 0 25px #0f0;
border: 2px solid #0f0;
display: grid;
grid-template-columns: 300px 1fr; /* left fixed, right flexible */
grid-template-rows: auto auto auto; /* 3 rows */
gap: 20px;
}

/* Individual sections */
.meter-section {
padding: 10px;
border-radius: 10px;
background: rgba(0, 32, 0, 0.8);
border: 1px solid #0f0;
}

/* Labels */
.meter-label {
font-size: 0.95em;
color: #b0ffb0;
margin-bottom: 4px;
}

.meter-sub-label {
font-size: 0.75em;
color: #88cc88;
margin-bottom: 6px;
}

/* Big digital values */
.meter-value {
font-family: "Consolas", "Courier New", monospace;
font-size: 1.9em;
letter-spacing: 0.08em;
padding: 8px 12px;
border-radius: 6px;
background-color: #001100;
color: #7CFC00;
box-shadow: inset 0 0 12px #0f0;
border: 1px solid #0f0;
}

.value-tag {
font-weight: bold;
margin-right: 8px;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.8em;
background: #000;
box-shadow: 0 0 6px currentColor;
}

/* Colour coding */
.volt-tag {
color: #4da6ff; /* blue */
}

.amp-tag {
color: #ff9933; /* orange */
}

.ohm-tag {
color: #ffff66; /* yellow */
}

.calibrate {
color: #ff4444; /* bright red */
}

/* GRID POSITIONING */
#voltageBox {
grid-column: 1;
grid-row: 1;
}

#currentBox {
grid-column: 1;
grid-row: 2;
}

#ohmsBox {
grid-column: 1;
grid-row: 3;
}
/* Make each title line a 3‑column grid */
#VoltageAcrossDUT_titleBox,
#Current_titleBox,
#Ohms_titleBox {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
column-gap: 6px;
}

.avgValue {
font-family: monospace;
font-size: 0.95em;
margin-left: 10px;
}

.avgTag {
font-family: monospace;
font-size: 0.75em;
margin-left: 4px;
}

#avgVoltage, #avgVoltage_Tag {
color: #4da6ff; /* blue */
}
#avgCurrent, #avgCurrent_Tag {
color: #ff9933; /* orange */
}
#avgOhms, #avgOhms_Tag {
color: #ffff66; /*yellow*/
}

#graphBox {
grid-column: 2;
grid-row: 1 / span 3; /* spans all 3 rows */
display: flex;
flex-direction: column;
}

/* Graph inner container */
#graphContainer {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 0; /* remove padding so canvas fills fully */
}

#graphBoxTitleBox {
display: grid;
grid-template-columns: 130px 130px auto 30px;
align-items: center;
position: relative;
}

#graphBoxTitle {
grid-column: 2;
grid-row: 1;
}

#deltaValue {
grid-column: 3;
grid-row: 1;
color: #ffeb3b;
font-size: 12px;
font-family: monospace;
text-align: right;
padding-right: 4px;
}

#deltaTag {
grid-column: 4;
grid-row: 1;
margin-bottom: 4px;
}

/* Canvas fills the entire graph area */
#graphCanvas {
width: 100%;
height: 100%;
display: block;
}

/* Status bar */
#statusBar {
margin-top: 16px;
font-size: 0.9em;
}

#statusText {
color: #f80;
}

/* Footer */
#footer {
font-size: 0.8em;
text-align: center;
padding: 10px;
color: #888;
margin-top: 20px;
}

#footer a {
color: #66bbff;
text-decoration: none;
}

#footer a:hover {
text-decoration: underline;
}

📝 Notes for Makers

  1. These files must be placed inside a folder named data
  2. The folder must sit next to your .ino file
  3. Upload them using: Tools → ESP8266 LittleFS Data Upload
  4. The ESP8266 will then serve them automatically when you visit its IP address

Step 9: 🌐 Finding the ESP8266’s IP Address

🌐

Now that the firmware and the web interface files are uploaded, the next step is to find out where your Low‑Ohms Meter lives on your network. To access the web interface, you need the IP address assigned to the ESP8266.

This step explains how to get it, why it changes (or doesn’t), and what to expect from your router.

🧠 How IP Addresses Are Assigned

When the ESP8266 starts up in Station Mode (STA), it connects to your home WiFi network.

Your router then assigns it an IP address using DHCP (Dynamic Host Configuration Protocol).

A few important points:

  1. 🖥️ The router controls the IP address, not the ESP8266
  2. 🔢 You cannot reliably set the IP address from the ESP8266 side
  3. 🆔 Routers track devices by MAC address
  4. 🔁 Once a router assigns an IP to a device, it usually gives the same IP every time
  5. 🧹 The only time it changes is when:
  6. The router is rebooted
  7. The DHCP table is full
  8. The lease expires and the router reallocates addresses
  9. The ESP8266 connects to a different network

So in normal home use, the IP address tends to stay stable.

🛠️ How to See the Assigned IP Address

The easiest way is to use the Arduino IDE Serial Monitor.

✔️ Step 1 — Open Serial Monitor

In the Arduino IDE:

Tools → Serial Monitor

Set the baud rate to 115200.

✔️ Step 2 — Reset the ESP8266

Press the RESET button on the NodeMCU.

✔️ Step 3 — Watch the Boot Messages

On startup, the ESP8266 prints connection information, including:

Connecting to Wi-Fi...
Connected! IP: 192.168.x.x

The number shown after IP: is the address you will use in your browser.

✔️ Step 4 — Open the Web Interface

Enter the IP address into your browser:

http://192.168.x.x

You should now see the live Low‑Ohms Meter interface.

📌 What If You’re Using Access Point Mode?

If you selected Access Point (AP) or AP with password in Credentials.h, the ESP8266 creates its own WiFi network.

  1. Connect your phone or laptop to the ESP8266 WiFi
  2. The IP address is usually:

Code

192.168.4.1

But again, the Serial Monitor will confirm it.

📝 Notes for Makers

  1. If your router supports DHCP reservations, you can lock the ESP8266 to a fixed IP using its MAC address
  2. This is optional, but useful if you want the meter to always appear at the same address
  3. If the IP ever changes unexpectedly, simply check the Serial Monitor again.


Step 10: 🎯 Calibration — Achieving Instrument‑Grade Accuracy

🎯

The Low‑Ohms Meter is only as accurate as its calibration. This step ensures the meter knows the true value of the current shunt (R1) and therefore calculates resistance correctly.

The good news? Calibration is simple, repeatable, and only needs to be done once unless you change hardware.

🧠 Why Calibration Matters

The meter measures resistance using:

R=VDUTI

Where:

  1. VDUT comes from ADS1115 differential A0–A1
  2. I is calculated from the voltage across R1, the 1 Ω shunt

Even a tiny error in the actual value of R1 (e.g., 1.000 Ω vs 0.994 Ω) will cause a proportional error in all resistance readings.

The firmware includes:

#define CURRENT_SHUNT_R1 0.99438f

This value must match the real measured resistance of the R1 shunt.

🧪 What You Need

  1. Your assembled meter (breadboard or PCB)
  2. A precision multimeter capable of measuring current accurately
  3. A stable DUT (any resistor between 1–10 Ω works fine)
  4. USB power for the NodeMCU
  5. Serial Monitor (optional but helpful)

🔧 Step 1 — Enter Calibration Mode

Calibration mode is controlled by:

bool isCalibrateMode = false;

Set this to:

true

Then upload the firmware again.

When calibration mode is active:

  1. The OLED shows “Calibration”
  2. The display shows shunt voltage only
  3. The web interface shows calibration data

This mode isolates the shunt measurement so you can determine the true value of R1.

🔌 Step 2 — Insert an Ammeter in Series

To calibrate R1, you need to know the actual current flowing through the DUT.

Do this:

  1. Connect your DUT as normal
  2. Break one of the current wires
  3. Insert your multimeter in series set to measure current

Your setup should look like:

Meter → DUT → Ammeter → Meter

The ammeter now shows the true current.

📏 Step 3 — Read the Shunt Voltage

On the OLED (in calibration mode), you’ll see:

R1: 0.0xxxxx V

This is the voltage across the 1 Ω shunt.

Let’s call it:

Vshunt

🧮 Step 4 — Calculate the True Value of R1

Use Ohm’s Law:

R1 = Vshunt / Iactual

Where:

  1. Vshunt is from the OLED
  2. Iactual is from your ammeter

Example:

  1. OLED shows: 0.09944 V
  2. Ammeter shows: 0.1000 A

R1 = 0.09944 / 0.1000 = 0.9944 Ω

This is the value you place into:

#define CURRENT_SHUNT_R1 0.9944f

💾 Step 5 — Update the Firmware

  1. Edit the value in the .ino file
  2. Set isCalibrateMode back to false
  3. Upload the firmware again
  4. Upload the LittleFS data folder again (optional but recommended)

Your meter is now calibrated.

🧪 Step 6 — Verify Accuracy

Test with known resistors:

  1. 1 Ω
  2. 0.5 Ω
  3. 0.1 Ω
  4. 10 Ω

You should now see stable, accurate readings within the limits of the ADS1115 and your current source.

📝 Notes for Makers

  1. Calibration only needs to be done once unless R1 is changed
  2. Temperature drift of R1 is minimal at these currents
  3. The ADS1115 is factory‑trimmed and extremely stable
  4. Your firmware uses floating‑point math for precision
  5. The OLED and web interface both update in real time

Step 11: 🧪 How to Use the Meter — Understanding the 4-Probe Setup

🧪

The Low‑Ohms Meter uses a 4-wire Kelvin connection to measure resistance accurately — even down to milliohms. This step explains:

  1. Why there are 4 probes
  2. What each pair does
  3. How to connect them to your DUT
  4. How to interpret the readings

🧠 Why 4 Probes?

Standard multimeters use 2 probes — they push current through the DUT and measure voltage across the same contacts. This introduces lead resistance, contact resistance, and probe pressure errors — all of which swamp low‑ohm readings.

Your meter uses 4 probes:

  1. 🔵🔶 Two for current — to push a known current through the DUT
  2. 🔴⚫ Two for voltage — to measure the voltage across the DUT

This isolates the voltage measurement from the current path — eliminating contact resistance and lead losses.

This is called a Kelvin connection, and it’s the gold standard for low‑resistance measurement.

🔌 Probe Breakdown

Here’s what each probe does:

Table

The voltage probes connect inside the current path — directly across the DUT — but carry no current. This ensures the voltage reading is pure, unaffected by wire or contact resistance.

🧪 How to Connect to a DUT

  1. Connect the blue and orange probes to either end of the DUT — this completes the current path
  2. Connect the red and black probes to the same points — ideally as close to the DUT body as possible
  3. Make sure the sense probes are inside the current path, not outside the clips or leads
  4. The OLED and web interface will show:
Current: 0.1000 A
Voltage: 0.000375 V
Resistance: 0.003538 Ω

📏 Interpreting the Readings

  1. Current is fixed by your range switch (100 mA or 10 mA)
  2. Voltage is measured across the DUT
  3. Resistance is calculated using Ohm’s Law:

R = VDUT / I

The OLED and web interface both show:

  1. Live readings
  2. Averages
  3. Graphs (web only)
  4. Calibration mode (if enabled)

📝 Notes for Makers

  1. Use firm probe connections — loose clips will affect readings
  2. Keep sense wires short and direct
  3. Avoid measuring across long leads or solder joints
  4. For best results, use Kelvin clips or 4-pin sockets on the PCB version

Step 12: 🧩 Building the PCB Version (PCBWay)


Currently I have the PCB on order and waiting delivery.

As soon as I have the PCB delivered I will update this section with a link to my shared projects page.


🧩PCB from PCBWay.

Now that you’ve proven the circuit on a solderless breadboard, you can build a professional, reliable, low‑profile version using the custom PCB I designed for this project.

I had the board manufactured by PCB from PCBWay., and I’ll also be sharing this project on their Shared Projects page. If you order the board through that link, PCBWay gives me a small credit — it helps support future open‑source builds like this one.

🟦 Why Use a PCB?

The PCB version gives you:

  1. A clean, compact layout
  2. Proper Kelvin probe routing with controlled geometry
  3. A solid mechanical base for the NodeMCU, ADS1115, and OLED
  4. Reduced noise and improved measurement stability
  5. A choice between socketed modules or a low‑profile soldered build
  6. A professional look that’s ideal for a bench instrument

All components are identical to the breadboard version — the only difference is the probe connector, which is now a proper PCB‑mounted socket.

🟩 Ordering the PCB from PCB from PCBWay.

To keep this project sustainable and to support future open‑source builds, the PCB for this meter is not provided as a Gerber download. Instead, the board is available directly through PCB from PCBWay.’s Shared Projects platform.

This means you can order the exact PCB I designed with a single click, and PCB from PCBWay. gives me a small credit for each board ordered — which helps fund more projects like this one.

✔️ How to Get the PCB

Once the project is published on PCB from PCBWay.’s Shared Projects page, you’ll be able to:

  1. Visit the project link
  2. See photos, specs, and the board preview
  3. Choose your solder mask colour
  4. Add it to your cart
  5. Order it just like any other PCBWay board

PCBWay handles everything — no Gerber uploads needed.

They typically manufacture and ship within a few days.

🟧 Assembly Options

The PCB is designed to support two different build styles, depending on whether you want modularity or a slim, permanent build.

1️⃣ Option A — Socketed Modules (Beginner‑Friendly)

This version uses female header sockets soldered to the PCB. The NodeMCU, ADS1115, and OLED simply plug in.

✔️ Advantages

  1. Easy to replace modules
  2. No risk of overheating the boards
  3. Great for experimenting or upgrading later
  4. Ideal for beginners or first‑time builders

✔️ What You Need

  1. Female header strips (2.54 mm)
  2. Straight pin headers for the modules

✔️ Assembly Notes

  1. Solder the sockets first
  2. Plug the modules in only after all sockets are aligned
  3. The OLED can be mounted either flush or raised depending on your preference

2️⃣ Option B — Low‑Profile Build (What I’m Using)

For a compact, professional look, you can solder the modules directly to the motherboard using pin headers only — no sockets.

✔️ Advantages

  1. Much lower profile
  2. Stronger mechanical assembly
  3. Cleaner appearance
  4. Slightly better electrical performance (shorter connections)

✔️ What You Need

  1. Straight pin headers
  2. A steady hand
  3. A willingness to commit the modules permanently

✔️ Assembly Notes

  1. Solder the pin headers into the PCB first
  2. Then place the modules on top and solder from above
  3. Keep the OLED as close to the PCB as possible for a neat finish
  4. The ADS1115 sits flat and tidy with this method

This is the version shown in the video.

🟨 Probe Connector

The only part that differs from the breadboard version is the Kelvin probe socket. The PCB uses a proper 4‑pin connector footprint, giving you:

  1. A secure mechanical connection
  2. Correct spacing for Kelvin clips
  3. Clear silkscreen labels for TP1–TP4
  4. Reduced wiring clutter

If you prefer, you can still solder wires directly to the pads.

🟪 Final Assembly

Once the modules are mounted:

  1. Attach the probe connector
  2. Add the range switch
  3. Plug in USB power
  4. Upload the firmware (same as breadboard version)
  5. Upload the LittleFS data folder
  6. Calibrate once
  7. You’re ready to measure

🧠 OLED Pinout Compatibility — Why I Added Solder Jumpers

One thing I’ve learned from building with these OLED modules is that not all of them follow the same pin order. Some have:

  1. GND and VCC flipped
  2. SCL and SDA swapped
  3. Or even all four pins in a different sequence

To make this PCB compatible with the widest range of OLEDs, I added solder jumpers that let you reconfigure the connections.

🔧 How It Works

Each OLED pin (GND, VCC, SDA, SCL) connects to a 3-pin jumper on the PCB:

  1. The centre pin goes to the OLED
  2. You bridge either the left or right pad to select the signal
  3. This lets you flip GND/VCC or swap SDA/SCL as needed

You only need to solder one bridge per jumper — either left or right, depending on your OLED’s pinout.

✔️ Benefits

  1. No need to modify the OLED module
  2. No risk of reverse power damage
  3. Works with common OLEDs from AliExpress, Amazon, eBay, etc.
  4. Makes the PCB more beginner-friendly and future-proof

📝 Notes for Makers

  1. Check your OLED’s pinout before soldering the jumpers
  2. Use a multimeter or datasheet to confirm the order
  3. Once set, the jumpers don’t need to be changed again
  4. If you’re unsure, start with the breadboard version to test your OLED first

The PCB version behaves identically to the breadboard version — just cleaner, sturdier, and more reliable.