A sensor circuit can fail in several places at once: its supply may dip, a divider may load its input, or a control output may change late. Moving one probe between those points loses the sequence. This expansion keeps four red voltage probes connected and shows four readings on the original Pico MiniMeter screen.

Verification: this is a locally reviewed learning prototype, not a calibrated or fault-rated multimeter. Firmware behavior has been exercised on CPython with synthetic buses, clocks and pins. It has not been run on a Pico or physically tested. All voltages, settling times and scan durations below are design calculations or proposed observations.

Four points, one reference

The four channels measure positive 0–12 V DC against one shared COM. They suit slowly changing, low-energy bench circuits. There is no current socket, mains rating or channel isolation. One black lead goes to the experiment's common ground; each red lead goes to a different node. Several black clips attached to different voltages would short those nodes together.

The instrument displays readings together but acquires them in sequence. This is useful for a supply, a sensor output and two slow control points. It is unsuitable for measuring phase, comparing brief pulses, or deciding which of two fast events happened first. Subtracting two channel values is only a useful voltage difference when the nodes remain sufficiently stable across the scan.

This article expands the original Pico MiniMeter. Its first ADC already serves voltage, resistance and excitation sensing. Keep that hardware intact and add a second ADC. The scan profile reads only the new board. Return to the original program for resistance or continuity, after disconnecting every voltage probe and powering off the circuit under test. During scanning, unplug the original Ω test lead: software cannot prevent the original button's mechanical contact from applying excitation.

Exact modules and additional parts

Use the original non-wireless Raspberry Pi Pico RP2040 Rev3, its battery supply and Adafruit #4440 128×32 SSD1306 OLED. The LED handling assumes GP25; a Pico W is not a drop-in replacement for this program. For the new ADC use Adafruit #1085 ADS1115, the STEMMA QT revision with a rear A0 address jumper. The drawing uses module header names, not bare-chip pin positions. Verify the actual PCB revision; older address-pin boards need their own documented wiring.

Added itemQuantity / specificationPurpose
ADS1115 module U41 × Adafruit #1085, specified revisionFour-input analog-to-digital converter
100 kΩ and 20 kΩ resistors4 of each, 0.1%, ≥0.125 WIndependent voltage dividers
470 Ω resistors4, 1%, ≥0.125 WSeries isolation before each filter capacitor
C5–C84 × 100 nF ceramic, ≥16 VInput filtering
D4–D74 × Nexperia BAS70-04, SOT23, on suitable adaptersTwo clamp diodes per input; exact suffix matters
C9, C10100 nF ceramic + 10 µF, ≥10 VLocal supply bypassing at U4
ConnectionsFour labelled red sockets, one common black socket, short wiresProbe identification and shared reference
Optional experiments1 kΩ ≥0.125 W resistor; break-before-make SPDT switch; parts from the linked 5 V RC lessonShared-step and capacitor-charging tests
Four independent 100k/20k divider networks with 470 ohm series resistors, 100 nF filters and pin-labelled BAS70-04 clamps feed ADS1115 A0–A3.
Original Tricky Circuit schematic, sheet 1 of 2. Design values only; no physical measurements. Open full size before wiring. Editable SVG

For CH1, R10 = 100 kΩ connects the red socket to SCAN_DIV1; R11 = 20 kΩ connects SCAN_DIV1 to COM. R12 = 470 Ω joins SCAN_DIV1 to SCAN0. C5 = 100 nF joins SCAN0 to COM. D4 pin 3 joins SCAN0, pin 1 joins COM, and pin 2 joins 3V3. Repeat the separate network for CH2–CH4 as drawn, ending at SCAN1–SCAN3. Do not join any two SCAN_DIV or SCAN nets. Same-name COM and 3V3 labels do connect. SCAN0–SCAN3 belong only to the new ADC U4; never join them to the original ADC U2’s AIN0/AIN1 nets. New component references start after the existing meter’s references.

The Nexperia BAS70-04 has pin 1 = first anode, pin 2 = second cathode, pin 3 = their internal junction. Read its SOT23 package drawing before orienting an adapter; the schematic symbols are not a package view. These modest clamps do not establish an overvoltage rating. Their forward drop is not guaranteed to keep the ADC within its absolute limits under every fault. Reverse polarity, high energy and measuring while the instrument is off remain outside this design.

Pico header connections, second ADC address 0x49, existing ADC 0x48, OLED 0x3C, battery polarity and local decoupling.
Original Tricky Circuit integration schematic, sheet 2 of 2. Retain the original MiniMeter power circuit; all channels share COM. Editable SVG

With all power removed, close the new module's rear address jumper A0 to select 0x49. It is distinct from analog input A0. Keep the old ADC at 0x48 and OLED at 0x3C. Wire U4 VIN to Pico pin 36 (3V3 OUT), GND to COM, SDA to GP0 (physical pin 1), and SCL to GP1 (physical pin 2). Leave ALRT unconnected because the code polls conversion completion. Leave the board's A+ and A− auxiliary outputs externally unconnected in this version. Never feed those outputs from a separate supply.

Place C9 and C10 across U4 VIN/GND with short returns; a polarized C10 has its positive end at 3V3. Existing board pull-ups appear in parallel. Do not automatically add more: inspect the actual modules and confirm the bus rises and pulls low correctly before increasing wire length. The code starts at 100 kHz.

Retain the original battery and control schematic: three AAA cells, positive through S0 and a Vishay 1N5817 with its banded cathode toward VSYS pin 39; battery negative to COM. Retain the 3.3 kΩ rail bleeder, existing decoupling and button connections. Disconnect probes before attaching USB or switching the meter off. Battery operation removes the computer connection; it does not isolate one channel from another.

What each probe does to the circuit

Ignoring ADC loading, the divider gives VADC = VIN × 20k / (100k + 20k) = VIN / 6. At 12 V the ADC node is nominally 2 V, while current through the divider is 100 µA. The 100 kΩ resistor dissipates 1 mW. The approximate socket input resistance is 120 kΩ per channel, much lower than many commercial meters.

That load is part of the experiment. Suppose a sensor's unloaded output is 3 V with a 10 kΩ source resistance. One channel changes it to about 3 × 120k / 130k = 2.77 V. Attaching all four channels to that same node puts four 120 kΩ loads in parallel: approximately 30 kΩ. The node then becomes 3 × 30k / 40k = 2.25 V. Calibrating the meter cannot undo a real change in the measured circuit.

The 470 Ω resistor is not the entire filter resistance. With a low-impedance source, the capacitor sees approximately (100k ∥ 20k) + 470 = 17.14 kΩ. With 100 nF this gives τ ≈ 1.71 ms and a first-order corner near 93 Hz. About 8.6 ms is five time constants. These are ideal front-end calculations, not measured ADC settling performance; source resistance, ADC switched input loading and leakage also matter. A step may occur anywhere inside a conversion window.

The TI ADS1115 recommended analog-pin range is ground to its supply. The selected ±4.096 V PGA scale describes conversion scaling, not permission to exceed the 3.3 V supply. The absolute pin limits are stress limits, not normal operating targets. At that scale one ADC count corresponds nominally to 125 µV, or 0.75 mV at our socket after multiplying by six. Resolution is not accuracy. Divider tolerances, loading, offsets and temperature remain.

Calibrate every channel separately

For each input, obtain several settled raw ADC voltages with the socket connected to COM, then with a known positive low-impedance reference measured by a trusted meter. Choose a midrange reference such as approximately 5 V, not the 12 V boundary. Use only a known, current-limited low-voltage setup. Record the actual reference, raw zero, raw reference reading, supply, temperature and date. Do not copy someone else's calibration numbers.

For a zero-and-reference calibration, ZERO[ch] = ADC_zero and FACTOR[ch] = Vref / (ADC_ref − ADC_zero). The program calculates (ADC − ZERO[ch]) × FACTOR[ch]. Replace all four pairs in scan_four.py, then check separate intermediate voltages. Set CALIBRATED = True only after those real checks; it changes the display flag, not the hardware quality.

To inspect raw values from the MicroPython REPL, first stop the scanning program. Connecting USB joins COM to the computer, so connect it before the calibration probes and use a deliberately common-ground bench setup. The following commands use the same driver and PGA setting; change the channel number from 0 through 3. They are a measurement procedure, not pre-recorded results:

from machine import Pin, SoftI2C
from pico_minimeter import ADS1115, median
bus = SoftI2C(sda=Pin(0), scl=Pin(1), freq=100000, timeout=50000)
adc = ADS1115(bus, address=0x49)
channel = 0
print(median([adc.volts(channel, 1) for _ in range(5)]))

The time hidden behind four numbers

The ADS1115 selects one input at a time. This profile takes five completed conversions on CH1, then five on CH2, CH3 and CH4. The median rejects an isolated in-range outlier; it cannot align different acquisition times.

Calculation at nominal 128 conversions/sConversion time only
One conversion1000 / 128 = 7.8125 ms
One channel, five samples39.0625 ms
Four channels, five samples each156.25 ms
CH1 to CH4 window-start separation117.1875 ms

These numbers exclude bus work, display transfer, polling, oscillator variation and the program's 60 ms pause. They are not a tested refresh rate. Each recorded start_ms/end_ms bounds software acquisition; neither claims an instantaneous sample aperture. The OLED age is measured conservatively from the start of that channel's window. Earlier channels are older when the full frame is displayed.

Use a simultaneous-sampling converter when the question truly requires aligned measurements. The TI ADS131M04 illustrates that architecture, but it needs its own input conditioning, interface, clocking and software. It is not interchangeable with the ADS1115. Merely adding a second ADS1115 does not guarantee synchronization.

Three experiments and a fault check

  1. Identify every channel. With probes removed, verify power and scan addresses. Disconnect power before correcting wiring. Start with each new red input shorted to COM: the screen should show small near-zero values and U flags. Apply one known low voltage to CH1 only, then move it to CH2–CH4. Verify the expected row changes; keep all unused inputs connected to COM for this test.
  2. Observe time skew. With power off, connect all four red sockets to a common node fed through a 1 kΩ resistor from the common of a break-before-make SPDT switch. Its throws go to this meter's 3V3 and COM. The shared COM stays connected. Power up and switch between the two states. One frame can contain old and new levels because its channels were acquired in sequence. Contact bounce, input filtering and conversion averaging blur the transition. A single toggle may miss the visible window; repeat slowly. A stable all-high reading is expected near 3V3 × 30k / 31k, approximately 3.19 V for an actual 3.30 V rail, before other errors. This accounts for the four input loads.
  3. Separate scan skew from a slow waveform. Connect all four inputs to the capacitor node of the published 5 V RC charging experiment. Four probes load the node by approximately 30 kΩ. With its 10 kΩ charging resistor and 470 µF capacitor, the new ideal final voltage is 3.75 V and τ is (10k ∥ 30k) × 470µF = 3.525 s. At t = 0.5 s, the ideal curve is about 0.496 V; 117.1875 ms later it is about 0.602 V. That roughly 0.106 V difference can exist even if the four channels have identical calibration. These point values illustrate scale; real medians cover intervals and include overhead.
  4. Check failures deliberately. With every test lead removed, power off and disconnect U4; power up again. Expect an error pattern rather than valid readings. Restore it with power off, restart, and verify recovery using known safe inputs. A working display may show BUS. If the shared bus itself freezes, the OLED may retain an old image; use the Pico's changing heartbeat/triple-blink error indication and remove probes when health is uncertain. Do not treat a frozen number as live data.

For a timing log, enable USB_LOG only after reviewing the computer-ground connection. CSV includes scan number, channel, acquisition bounds, window length, age, completed sample count, voltage and status. Error values are empty. Tick counts are wrapping monotonic counters, not wall-clock timestamps. Preserve raw captures with the actual firmware settings and reference readings. Log physical outcomes in the bench record only after running the experiment; there are no physical results in this package.

Challenge: CH4 reads above CH1 while both touch one charging capacitor. Is CH4 miscalibrated?

Not necessarily. CH4 is sampled later, when a charging capacitor can be at a higher voltage. First compare all channels on a stable reference. Then reverse the channel acquisition order in a separate experimental copy: a timing-dependent ordering should follow acquisition order. Do not overwrite the baseline release while investigating. The four probes also change the RC circuit's load and time constant, so compare against the loaded model above.

Install the complete scan profile

API target: MicroPython 1.27 on the original Pico. Download scan_four.py and the unchanged pico_minimeter.py into the same filesystem directory. The latter supplies the ADC/OLED drivers and numeric helpers; importing it does not run the original meter loop. Replace main.py with the two lines below for this profile. Keep a backup of the original main.py to restore single-meter operation. All three required sources are reproduced here.

import scan_four
scan_four.run()

The scanner publishes only a complete frame. A bus exception clears all channel records. A range error clears the affected channel; every individual sample is checked before taking the median so an overrange excursion is not hidden by filtering. The inherited voltage helper tolerates up to 30 mV of negative calculated zero error and clips that to zero; more negative values and values above 12 V are invalid. This software rule is not electrical input protection.

START means no acquisition yet; RANGE means an invalid conversion or calibration-adjusted value; BUS means communication/completion failed; CAL means invalid calibration; OHM HELD means the old button is pressed. Values aged 500 ms or more become STALE on the next snapshot. The watchdog restarts a stalled program, but it cannot make a frozen display's old image trustworthy. U/C flags mean calibration not recorded/recorded by the builder. The piezo remains silent.

scan_four.py

"""MiniMeter four-channel profile, development v0.1.
Pico RP2040 (non-wireless), second ADS1115 #1085 at 0x49, OLED #4440.
Requires the unchanged batch-003/code/pico_minimeter.py alongside this file.
MicroPython 1.27 API target; only CPython/fake-interface validation so far.
Four positive 0..12 V inputs, independent dividers, ONE shared COM.
Disconnect the original OHM lead. This profile never measures resistance.
main.py: import scan_four; scan_four.run()
"""
import math
import time
from pico_minimeter import ADS1115, OLED128x32, median, voltage_from_adc

ADDRESS = 0x49
OLED_ADDRESS = 0x3C
SAMPLES = 5
STALE_MS = 500
# Replace independently with real per-channel calibration observations.
ZERO = (0.0, 0.0, 0.0, 0.0)
FACTOR = (6.0, 6.0, 6.0, 6.0)
CALIBRATED = False
USB_LOG = False  # USB connects COM to computer ground; review wiring first.


class OhmHeld(Exception):
    pass


class Scanner:
    def __init__(self, adc, clock=time, zero=ZERO, factor=FACTOR):
        if len(zero) != 4 or len(factor) != 4:
            raise ValueError('Four calibration pairs required')
        if not all(math.isfinite(x) for x in tuple(zero) + tuple(factor)):
            raise ValueError('Non-finite calibration')
        if any(x <= 0 for x in factor):
            raise ValueError('Positive gains required')
        self.adc, self.clock = adc, clock
        self.zero, self.factor = tuple(zero), tuple(factor)
        self.sequence = 0
        self.records = self._invalid('START')
        self.last_fault = None

    def _invalid(self, status):
        return [dict(channel=i+1, value=None, status=status,
                     start_ms=None, end_ms=None, samples=0)
                for i in range(4)]

    def invalidate(self, status):
        self.records = self._invalid(status)

    def acquire(self, ohm_held=lambda: False):
        """Publish only a whole completed scan, never a partially updated frame.
        Timestamps bound software acquisition, not exact converter aperture.
        Any bus fault invalidates ALL four channels. Range faults are local.
        """
        self.sequence += 1
        self.last_fault = None
        frame = []
        try:
            if ohm_held():
                raise OhmHeld()
            for ch in range(4):
                start = self.clock.ticks_ms()
                values = []
                status = 'OK'
                try:
                    for _ in range(SAMPLES):
                        if ohm_held():
                            raise OhmHeld()
                        raw = self.adc.volts(ch, 1)  # +/-4.096 V PGA, 128 SPS
                        if ohm_held():
                            raise OhmHeld()
                        # Check every sample BEFORE median; don't hide excursions.
                        values.append(voltage_from_adc(raw, self.zero[ch], self.factor[ch]))
                except ValueError:
                    status = 'RANGE'
                frame.append(dict(channel=ch+1,
                                  value=median(values) if status == 'OK' else None,
                                  status=status, start_ms=start,
                                  end_ms=self.clock.ticks_ms(), samples=len(values)))
            self.records = frame
        except OSError as exc:
            self.last_fault = str(exc)
            self.invalidate('BUS')
        except OhmHeld:
            self.invalidate('OHM HELD')
        return self.snapshot()

    def snapshot(self):
        """Age is measured from the START of each acquisition window.
        ticks_diff handles normal tick wrap. Call within half the tick period.
        """
        now = self.clock.ticks_ms()
        rows = []
        for old in self.records:
            row = dict(old)
            row['sequence'] = self.sequence
            row['age_ms'] = None
            row['window_ms'] = None
            if row['start_ms'] is not None:
                age = self.clock.ticks_diff(now, row['start_ms'])
                row['age_ms'] = age
                row['window_ms'] = self.clock.ticks_diff(row['end_ms'], row['start_ms'])
                if row['status'] == 'OK' and (age < 0 or age >= STALE_MS):
                    row['status'], row['value'] = 'STALE', None
            rows.append(row)
        return rows


def display_lines(rows, calibrated=CALIBRATED):
    # Four lines, 16 characters max. U = uncalibrated; C = calibrated by owner.
    flag = 'C' if calibrated else 'U'
    return [('%d%s %.2fV %dms' % (r['channel'], flag, r['value'], r['age_ms']))
            if r['status'] == 'OK'
            else '%d%s %s' % (r['channel'], flag, r['status']) for r in rows]


def csv_lines(rows):
    """Empty value on faults, with scan number and acquisition-window bounds."""
    result = []
    for r in rows:
        fields = (r['sequence'], r['channel'], r['start_ms'], r['end_ms'],
                  r['window_ms'], r['age_ms'], r['samples'],
                  ('%.5f' % r['value']) if r['value'] is not None else None,
                  r['status'])
        result.append(','.join('' if v is None else str(v) for v in fields))
    return result


def run():
    from machine import Pin, SoftI2C, WDT
    import framebuf
    led = Pin(25, Pin.OUT, value=0)  # original Pico only, not Pico W
    Pin(16, Pin.OUT, value=0)  # piezo stays silent in voltage-only profile
    button = Pin(15, Pin.IN, Pin.PULL_UP)
    watchdog = WDT(timeout=2000)
    scanner, display = None, None
    beat = 0
    sequence = 0
    if USB_LOG:
        print('scan,channel,start_ms,end_ms,window_ms,age_ms,samples,volts,status')
    while True:
        try:
            bus = SoftI2C(sda=Pin(0), scl=Pin(1), freq=100000, timeout=50000)
            devices = bus.scan()
            if ADDRESS not in devices or OLED_ADDRESS not in devices:
                raise OSError('Need ADC 0x49 and OLED 0x3C')
            # Existing original ADC 0x48 is deliberately not sampled here.
            scanner = Scanner(ADS1115(bus, address=ADDRESS))
            scanner.sequence = sequence
            display = OLED128x32(bus, framebuf)
            while True:
                scanner.acquire(lambda: not button.value())
                sequence = scanner.sequence
                rows = scanner.snapshot()
                display.lines(display_lines(rows))
                if USB_LOG:
                    for line in csv_lines(rows):
                        print(line)
                if scanner.last_fault is not None:
                    raise OSError(scanner.last_fault)
                beat ^= 1
                led.value(beat)  # must keep changing; a steady LED is not health
                watchdog.feed()
                time.sleep_ms(60)
        except (OSError, ValueError) as exc:
            error = 'CAL' if isinstance(exc, ValueError) else 'BUS'
            if scanner:
                scanner.invalidate(error)
            try:
                if display:
                    flag = 'C' if CALIBRATED else 'U'
                    display.lines(['%d%s %s' % (i+1, flag, error) for i in range(4)])
            except OSError:
                pass  # shared-bus failure can leave the OLED physically frozen
            # Independent indication; remove probes if heartbeat stops/errors.
            for _ in range(3):
                led.value(1); time.sleep_ms(80)
                led.value(0); time.sleep_ms(80)
            watchdog.feed()
            time.sleep_ms(200)


if __name__ == '__main__':
    run()
Complete dependency: pico_minimeter.py
"""Tricky Circuit MiniMeter v0.1, Pico RP2040 + ADS1115 + OLED #4440.
MicroPython 1.27 API target. Low-energy, positive 0..12 V DC only.
Not hardware tested. All ground-labelled points are one electrical net.
Save as pico_minimeter.py; main.py: import pico_minimeter; pico_minimeter.run()
"""
import math
import time

# Replace these only with measurements made during the article's calibration.
CALIBRATED = False
CAL_VIEW = False                   # OLED raw readings for calibration
V_ZERO = 0.0                       # ADC volts, A0 at +/-4.096 V, input shorted
V_FACTOR = 6.0                     # socket volts / zero-corrected A0 volts
R_ZERO = 0.0                       # ADC volts, A1 at +/-1.024 V, OHM shorted
R_FACTOR = 6.0                     # socket volts / zero-corrected A1 volts
R_REFERENCE = 10000.0              # R7, measured before assembly
R_LOAD = 120000.0                  # input network; refine using fit_resistance()
PRECHECK_FACTOR = 6.0              # rough A1 scale at +/-4.096 V
ADS_ADDRESS = 0x48
OLED_ADDRESS = 0x3C


def config_word(channel, pga):
    if channel not in (0, 1, 2, 3) or pga not in (1, 3):
        raise ValueError('Unsupported ADC channel or gain')
    # Single-ended MUX, start, single-shot, 128 samples/s, comparator disabled.
    return 0x8000 | ((4 + channel) << 12) | (pga << 9) | 0x0100 | 0x0080 | 3


def signed16(raw):
    return raw - 65536 if raw & 0x8000 else raw


def median(values):
    if not values:
        raise ValueError('No samples')
    s = sorted(values)
    return s[len(s) // 2]


class ADS1115:
    def __init__(self, bus, clock=time, address=ADS_ADDRESS):
        self.bus, self.clock, self.address = bus, clock, address

    def register(self, reg):
        data = self.bus.readfrom_mem(self.address, reg, 2)
        if len(data) != 2:
            raise OSError('Short ADC read')
        return data[0] << 8 | data[1]

    def wait_ready(self, timeout_ms=50):
        start = self.clock.ticks_ms()
        while not self.register(1) & 0x8000:
            if self.clock.ticks_diff(self.clock.ticks_ms(), start) >= timeout_ms:
                raise OSError('ADC conversion timeout')
            self.clock.sleep_ms(1)

    def volts(self, channel, pga=1):
        self.wait_ready()
        cfg = config_word(channel, pga)
        self.bus.writeto_mem(self.address, 1, bytes((cfg >> 8, cfg & 255)))
        self.clock.sleep_ms(2)  # do not accept an immediate old ready state
        self.wait_ready()
        code = signed16(self.register(0))
        if code >= 32760 or code <= -32760:
            raise ValueError('ADC full scale')
        return code * (4.096 if pga == 1 else 1.024) / 32768.0

    def filtered(self, channel, pga=1, samples=5):
        return median([self.volts(channel, pga) for _ in range(samples)])


def voltage_from_adc(adc, zero=V_ZERO, factor=V_FACTOR):
    if not all(math.isfinite(x) for x in (adc, zero, factor)) or factor <= 0:
        raise ValueError('Invalid voltage calibration')
    v = (adc - zero) * factor
    if v < -0.03 or v > 12.0:
        raise ValueError('Outside 0..12 V')
    return max(0.0, v)


def resistance_from_adc(adc, excitation, zero=R_ZERO, factor=R_FACTOR,
                        reference=R_REFERENCE, load=R_LOAD):
    if not all(math.isfinite(x) for x in (adc, excitation, zero, factor, reference, load)):
        raise ValueError('Invalid resistance input')
    if not 3.0 <= excitation <= 3.6 or min(factor, reference, load) <= 0:
        raise ValueError('Check rail/cal')
    vx = (adc - zero) * factor
    if vx < -0.02 or vx > excitation + 0.02:
        raise ValueError('Check OHM input')
    if vx <= 0:
        return 0.0
    # Unknown resistor is in parallel with the permanently connected divider.
    y = vx / excitation
    conductance = (1.0 - y) / (reference * y) - 1.0 / load
    if conductance <= 0:
        return None
    r = 1.0 / conductance
    # Allow floating-point rounding at the endpoint, not an accuracy allowance.
    return None if r > 100000.0 * (1 + 1e-9) else min(r, 100000.0)


def fit_resistance(r1, y1, r2, y2):
    """Fit effective reference/load from two KNOWN resistors, 1k and 47k suggested.
    y is calibrated OHM socket voltage divided by measured excitation voltage.
    No invented observations or nominal precision claims.
    """
    if not all(math.isfinite(x) for x in (r1, y1, r2, y2)):
        raise ValueError('Non-finite calibration')
    if min(r1, r2) <= 0 or r1 == r2 or not (0 < y1 < 1 and 0 < y2 < 1):
        raise ValueError('Bad reference readings')
    q1, q2 = 1.0 / y1 - 1.0, 1.0 / y2 - 1.0
    rr = (q1 - q2) / (1.0 / r1 - 1.0 / r2)
    intercept = q1 - rr / r1
    if rr <= 0 or intercept <= 0:
        raise ValueError('Calibration has no physical fit')
    rl = rr / intercept
    if not (8000 < rr < 12000 and 90000 < rl < 150000):
        raise ValueError('Check wiring/reference values')
    return rr, rl


class Continuity:
    def __init__(self):
        self.on = False

    def update(self, resistance, valid=True):
        if not valid or resistance is None or not math.isfinite(resistance) or resistance < 0:
            self.on = False
        elif self.on:
            self.on = resistance < 80.0
        else:
            self.on = resistance < 40.0
        return self.on


class OhmGate:
    """A warning gate, not a safety disconnect: S1A switches R7 mechanically."""
    def __init__(self, clock=time):
        self.clock = clock
        self.quiet_since = None
        self.last_quiet = None
        self.pressed = False
        self.allowed = False

    def release(self, socket_volts, now):
        self.pressed = self.allowed = False
        if math.isfinite(socket_volts) and abs(socket_volts) <= 0.03:
            if self.quiet_since is None:
                self.quiet_since = now
            self.last_quiet = now
        else:
            self.quiet_since = self.last_quiet = None

    def press(self, now):
        if not self.pressed:
            self.allowed = (self.quiet_since is not None and self.last_quiet is not None
                            and self.clock.ticks_diff(now, self.quiet_since) >= 300
                            and self.clock.ticks_diff(now, self.last_quiet) <= 300)
            self.pressed = True
            self.quiet_since = self.last_quiet = None
        return self.allowed


class OLED128x32:
    def __init__(self, bus, fb_module, address=OLED_ADDRESS):
        self.bus, self.address = bus, address
        self.data = bytearray(512)
        self.fb = fb_module.FrameBuffer(self.data, 128, 32, fb_module.MONO_VLSB)
        # SSD1306, 128x32, internal charge pump, horizontal addressing.
        for cmd in (0xAE, 0xD5, 0x80, 0xA8, 0x1F, 0xD3, 0x00, 0x40,
                    0x8D, 0x14, 0x20, 0x00, 0xA1, 0xC8, 0xDA, 0x02,
                    0x81, 0x7F, 0xD9, 0xF1, 0xDB, 0x20, 0xA4, 0xA6, 0xAF):
            self.command(cmd)
        self.lines(['MINIMETER v0.1', 'STARTING', 'REMOVE PROBES', ''])

    def command(self, value):
        self.bus.writeto(self.address, bytes((0x00, value)))

    def lines(self, lines):
        self.fb.fill(0)
        for i, text in enumerate(lines[:4]):
            self.fb.text(str(text)[:16], 0, i * 8, 1)
        for cmd in (0x21, 0, 127, 0x22, 0, 3):
            self.command(cmd)
        self.bus.writeto(self.address, b'\x40' + self.data)


def run():
    from machine import Pin, SoftI2C, PWM, WDT
    import framebuf
    button = Pin(15, Pin.IN, Pin.PULL_UP)
    buzzer = PWM(Pin(16), freq=4000, duty_u16=0)
    led = Pin(25, Pin.OUT, value=0)  # original non-wireless Pico only
    watchdog = WDT(timeout=2000)
    display = None
    gate, beeper = OhmGate(), Continuity()
    status = 'CAL' if CALIBRATED else 'UNCAL'
    try:
        while True:
            try:
                bus = SoftI2C(sda=Pin(0), scl=Pin(1), freq=100000, timeout=50000)
                devices = bus.scan()
                if ADS_ADDRESS not in devices or OLED_ADDRESS not in devices:
                    raise OSError('Missing I2C device')
                adc = ADS1115(bus)
                display = OLED128x32(bus, framebuf)
                gate = OhmGate()  # no old permission after reconnect/reset
                while True:
                    buzzer.duty_u16(0)  # any read fault leaves sound off
                    heartbeat = '*' if (time.ticks_ms() // 500) % 2 else '.'
                    led.value(heartbeat == '*')
                    if button.value():
                        beeper.update(None)
                        raw_ohm = adc.filtered(1, 1, 3)
                        gate.release(raw_ohm * PRECHECK_FACTOR, time.ticks_ms())
                        raw_v = adc.filtered(0, 1)
                        if CAL_VIEW:
                            rail = adc.volts(2, 1)
                            raw_cal = adc.filtered(1, 3)
                            display.lines(['RAW ADC VOLTS', 'A0 %.6f' % raw_v,
                                           'A1 %.6f' % raw_cal, 'E %.4f %s' % (rail, heartbeat)])
                        else:
                            volts = voltage_from_adc(raw_v)
                            note = 'OHM: CHECK POWER' if abs(raw_ohm * PRECHECK_FACTOR) > 0.03 else 'HOLD OHM: TEST'
                            display.lines(['DC + 0..12V', '%.2f V' % volts, note, status + ' ' + heartbeat])
                    else:
                        time.sleep_ms(35)  # contact bounce and RC settling
                        if button.value():
                            continue
                        if not gate.press(time.ticks_ms()):
                            beeper.update(None)
                            display.lines(['OHM BLOCKED', 'RELEASE BUTTON', 'CHECK POWER/OHM', status + ' ' + heartbeat])
                        else:
                            e1 = adc.volts(2, 1)
                            raw_r = adc.filtered(1, 3)
                            e2 = adc.volts(2, 1)
                            if button.value():
                                continue
                            if abs(e1 - e2) > 0.02:
                                raise ValueError('Unstable rail')
                            excitation = (e1 + e2) / 2
                            resistance = resistance_from_adc(raw_r, excitation)
                            if CAL_VIEW:
                                display.lines(['RAW OHM / HELD', 'A1 %.6f' % raw_r,
                                               'E %.6f' % excitation, 'CAL VIEW ' + heartbeat])
                                beeper.update(None)
                            else:
                                sound = beeper.update(resistance)
                                buzzer.duty_u16(32768 if sound else 0)
                                reading = 'OL / >100k' if resistance is None else ('LOW (<1k)' if resistance < 1000 else '%.2f kOhm' % (resistance / 1000))
                                display.lines(['OHM (UNPOWERED)', reading,
                                               'CONTINUITY' if sound else 'RELEASE: DC V', status + ' ' + heartbeat])
                    watchdog.feed()
                    time.sleep_ms(60)
            except (OSError, ValueError) as exc:
                buzzer.duty_u16(0)
                beeper.update(None)
                gate = OhmGate()
                print('MiniMeter:', exc)
                try:
                    if display:
                        display.lines(['MEASUREMENT ERR', str(exc), 'REMOVE PROBES', 'RETRYING'])
                except OSError:
                    pass  # a failed shared bus can also freeze the display
                # Triple LED flash identifies errors even if OLED cannot update.
                for _ in range(3):
                    led.value(1); time.sleep_ms(80)
                    led.value(0); time.sleep_ms(80)
                watchdog.feed()
                time.sleep_ms(200)
    finally:
        buzzer.duty_u16(0)
        buzzer.deinit()


if __name__ == '__main__':
    run()

Local verification exercises the actual scan code and driver interfaces: channel order, medians, individual calibration, invalid inputs, partial-frame bus failures, stale boundaries and tick wrap, held-button handling, OLED line lengths and CSV failures. The actual run() loop is also exercised against synthetic ADC loss, missing address and shared-bus display failure. This is CPython behavior evidence, not a firmware build or a MicroPython/Pico execution. Bench work must still establish accuracy, settling, cross-channel effects, current consumption and error visibility.

Primary documentation and related lessons

Continue with the interactive electronics lessons and the loaded-divider experiment. The original MiniMeter article explains its power, resistance and continuity design.