A button works until your program changes an unrelated light. After that, releasing the button leaves its reading LOW. The wire is intact: the program has accidentally told the input pin to drive LOW itself. This small PCF8574 experiment makes that failure understandable and gives you a way to prevent it.
Verification status: an unbuilt, source-reviewed 3.3 V bench experiment. The delivered program has host behavioral tests using a model of the bus and pins. No Pico execution, electrical waveform, brightness or physical button result is claimed.
The Texas Instruments PCF8574N is an integrated circuit, or IC, that adds eight input/output connections to a controller. Input/output, abbreviated I/O, means a connection can receive or send a logic level. We use one pin to read a button and another to control a light-emitting diode, or LED. The original Raspberry Pi Pico with RP2040 runs the program.
On a quasi-bidirectional port, writing 0 makes the pin sink current toward ground, abbreviated GND. Writing 1 releases that strong LOW drive and leaves a much weaker source toward the positive rail. An external button can then pull the pin LOW. There is no separate direction register to set before each read. A register is a named storage location accessed through a device interface; this chip instead transfers its port byte directly.
A latch holds a written state until another write or a device reset changes it. A read returns the voltage interpreted at the physical pins, not a copy of that latch. A shadow is our program’s separate record of the last acknowledged command. Keeping these two values apart is the lesson. An acknowledgement, ACK, is the receiving device’s response during communication; it is evidence of a transaction, not a measurement of light or switch position.
The schematic uses I²C, Inter-Integrated Circuit, communication: serial data, SDA, and serial clock, SCL, are its two separate lines. Universal Serial Bus, USB, powers the Pico and carries the console. U1 is a 16-pin Plastic Dual In-line Package, PDIP, with two rows of through-hole pins.

The orderable part is TI PCF8574N, N / PDIP-16. The PCF8574A has a different address range. An unknown display backpack, another manufacturer’s part or a similarly named expander is not a verified substitute. Identify the marking and pin-one indicator on the actual package before wiring.
| Part | Choice | Job |
|---|---|---|
| Controller | Original non-wireless Pico / RP2040; MicroPython 1.26.0 | One owner of the expander’s command byte. Universal Serial Bus (USB) powers the complete low-voltage fixture and carries the console. |
| U1 | Texas Instruments PCF8574N, N / PDIP-16 | Quasi-bidirectional port; P0 is the button input and P4 controls the LED. |
| S1 | Normally-open momentary switch | Connects P0 to ground only while held. Check its actual contact pairs without power. |
| D1 | Kingbright WP7113ID, bare red through-hole LED, V.14A documentation | A visible low-current load; not a resistor-equipped WP7113ID5V or 14V variant. |
| R1, R2 | 4.7 kΩ each, ±5%, at least 0.125 W | Pull the two communication lines toward the shared 3.3 V rail. |
| R3 | 2.2 kΩ, ±5%, at least 0.125 W | Limits LED current. |
| R4 | 100 kΩ, ±5%, at least 0.125 W | A weak external pull-up on P4 helps keep its released level near the rail rather than depending on the LED path. |
| C1 | 100 nF ceramic, rated at least 10 V | Local supply bypass between U1 pins 16 and 8. |
The IC operates from 2.5–6 V, but this entire fixture uses the Pico’s regulated 3.3 V output. At a hypothetical 5 V chip supply, the documented HIGH input threshold of 0.7 times supply is 3.5 V: a 3.3 V controller would not meet it. A permitted chip supply is not permission to change the bus voltage. Keep both pull-ups, the LED branch and U1 on the same 3.3 V rail. Do not connect a 5 V bus or a second load supply.
I²C, Inter-Integrated Circuit, is the shared two-wire communication bus. Its serial data, SDA, line carries bits; its serial clock, SCL, line coordinates them. The lines use open-drain signaling: a device pulls LOW and releases the line for HIGH, with a resistor supplying the upward current. General-purpose input/output, GPIO, numbers on the Pico are different from physical header numbers.
| Net or function | Exact connection | Check before power |
|---|---|---|
| 3V3 | Pico 3V3_OUT physical pin 36 → U1 VCC pin 16; R1/R2 tops, R3 top, R4 top and C1 supply side | No VSYS or USB 5 V connection to this net. |
| Ground, GND | Pico physical pin 38 → U1 pin 8, S1 return and C1 return | All repeated ground references are one node, a set of electrically joined points. |
| SDA | Pico GP0 / physical 1 → U1 pin 15; R1 from this line to 3V3 | Separate from SCL even where the drawing crosses it. |
| SCL | Pico GP1 / physical 2 → U1 pin 14; R2 from this line to 3V3 | No short to SDA or ground. |
| Address | U1 A0 pin 1, A1 pin 2 and A2 pin 3 → GND | Fixed 7-bit address 0x20. Do not leave address pins floating. |
| Button | U1 P0 pin 4 → S1 → GND | Two-terminal switch function; not an external powered signal. |
| LED | 3V3 → R3 → D1 anode; D1 cathode → U1 P4 pin 9; R4 from P4 to 3V3 | Check the LED’s anode/cathode from its package drawing and actual part, not lead length alone. |
| Unused port pins | P1/5, P2/6, P3/7, P5/10, P6/11, P7/12: no external connection | The program always writes these six bits LOW, following the datasheet’s unused-output recommendation. |
| Interrupt | INT pin 13: no external connection | An interrupt is a notification to a controller. This polling experiment does not use it or claim to count every change. |
Keep the wires short and place C1 near U1’s supply pins. The schematic is a functional symbol with pin numbers, not a breadboard photograph or a top-view package layout. Pin one on the package remains the reference for assembly. Leave the Pico’s other power pins disconnected from this added circuit. Remove USB before changing any wire.
An address such as 0x20 is hexadecimal notation, a base-16 representation. It is the 7-bit value expected by the software interface, not the bus byte 0x40 with a write-direction bit appended. Finding an address during a scan does not identify the chip’s manufacturer or guarantee the correct wiring.
P4 is an active-low control: LOW makes the LED current path; released HIGH requests off. A sinking output carries current from the load into the chip and out through ground. It does not power the load by sourcing a strong HIGH. The IC’s absolute limits are stress boundaries, not normal operating targets or a promise that every pin can carry their sum.
As a conditional example only, suppose you later measure supply 3.30 V, LED forward drop 1.80 V and P4 LOW level 0.20 V. Then R3 current is (3.30 − 1.80 − 0.20)/2200 ≈ 0.591 mA. R4 adds about (3.30 − 0.20)/100000 = 0.031 mA, so P4 sinks about 0.622 mA. These are invented calculation inputs, not measured LED characteristics. The LED datasheet’s forward voltage at its stated test current must not be reused as a guarantee at our much smaller current.
A more conservative current ceiling ignores both voltage drops. If the rail were within an assumed ±5% of 3.3 V and the resistors at their −5% values, the two paths together would be below 3.465/2090 + 3.465/95000 ≈ 1.694 mA. This bounds our resistor-limited circuit current; it does not guarantee P4’s LOW voltage or the LED’s brightness. The chip’s tabulated strong-sink characteristic uses a 5 V test supply, so verify the actual P4 level at 3.3 V rather than inventing a guaranteed resistance for its transistor.
Each 4.7 kΩ bus pull-up draws about 0.70 mA when its line is LOW at nominal 3.3 V. With the same hypothetical tolerances, its current is below 0.78 mA. For an estimated 200 pF bus capacitance and the +5% resistor, the standard rise-time approximation gives 0.8473 × 4935 × 200 pF ≈ 0.836 µs. That is a planning example below the 1 µs standard-mode rise-time limit, not a measurement of your leads. Use at most 100 kHz, the specified maximum for this chip; the datasheet’s generic application graph mentioning fast mode does not qualify this PCF8574 for 400 kHz.
The weak pull-up on a released P pin differs from the strong LOW drive, and a brief extra pull-up acts during a write of 1. Treat the button as a switch to ground on an input deliberately left at 1. Do not connect a push-pull signal that can drive HIGH against a written LOW, and do not use a P pin as a power supply. Neither the firmware nor R4 makes an overvoltage or unpowered-input protector.
The intended off byte is 0x11: P4 and P0 are 1, and the unused bits are 0. The on byte is 0x01: only P0 is 1. A cold power-on reset starts with all P bits at 1; our first explicit write establishes the fixture’s own policy. A reset of the Pico alone may leave the expander unchanged, so startup never trusts a cached assumption about it.
With the LED off, hold S1. P0 is physically LOW, and an ideal port read is now 0x10. A naive read-modify-write, RMW, operation reads that sample, clears P4 to turn the LED on, and sends 0x00. It has also written P0 LOW. Releasing the switch cannot raise a pin which is now actively driving LOW. In contrast, our update clears P4 in the acknowledged shadow 0x11, producing 0x01; it never copies the pressed button into the command.

For a short proof on a host computer, import the provided module and call naive_read_modify_write(0x10, True); it returns 0. That function demonstrates the mistake without applying it to a real device. A bitwise mask selects particular bits: 0x10 selects P4, 0x01 selects P0, and 0xEE selects our unused outputs.
Install MicroPython 1.26.0 for the original Pico. Save the complete file as pcf8574_readback_shadow.py on the board, using an underscore in that board filename. From the read-evaluate-print loop, REPL, the interactive Python console, run import pcf8574_readback_shadow; pcf8574_readback_shadow.main(). The download’s hyphenated filename is for the article; rename it as shown when copying to the board.
Download the complete program · Download the blank comma-separated values (CSV) observation sheet.
"""PCF8574N mixed input/output lab, original Pico, MicroPython 1.26.0.
P0: normally-open button to GND. P4: active-low LED through 2.2 kohm to 3V3.
Unused P1/P2/P3/P5/P6/P7 are unconnected LOW outputs. One device owner only.
A shadow is acknowledged command intent, never an output-latch measurement.
Host tests do not establish target timing or physical electrical behavior.
"""
import time
ADDRESS = 0x20
LED_MASK = 0x10
INPUT_MASK = 0x01
UNUSED_MASK = 0xEE
OUTPUT_MASK = 0xFE
OFF_BYTE = 0x11
def checked_byte(value):
if type(value) is not int or not 0 <= value <= 255:
raise ValueError('Expected an integer byte, not a pin object or boolean')
return value
def button_pressed(sample):
return not bool(checked_byte(sample) & 1)
def naive_read_modify_write(sample, led_on):
"""MODEL ONLY: show the bug; the console never sends this result to hardware."""
sample = checked_byte(sample)
if type(led_on) is not bool:
raise ValueError('led_on must be bool')
return sample & ~LED_MASK if led_on else sample | LED_MASK
class ShadowPort:
"""Only P4 changes; P0 stays 1 and every unused output stays 0 on every write.
None means unknown, including a transaction that may have reached the chip
before the host saw an error. Recovery explicitly writes LED-off / P0 released / unused pins LOW.
"""
def __init__(self, bus, clock=time, address=ADDRESS):
if type(address) is not int or not 0x20 <= address <= 0x27:
raise ValueError('This PCF8574 fixture uses a 7-bit address 0x20..0x27')
self.bus, self.clock, self.address = bus, clock, address
self.shadow = None
def _write(self, value):
value = checked_byte(value)
if value & INPUT_MASK != INPUT_MASK or value & UNUSED_MASK:
raise ValueError('P0 must stay 1 and unused bits must stay 0')
# Invalidate BEFORE starting I2C: even an interrupted ACK is uncertain.
self.shadow = None
count = self.bus.writeto(self.address, bytes((value,)))
if type(count) is not int or count != 1:
raise OSError('Incomplete one-byte port write; command state unknown')
self.clock.sleep_ms(10) # application pause, not a waveform guarantee
self.shadow = value
return value
def synchronise_off(self):
"""Explicit recovery; overwrites this entire port with 0x11."""
return self._write(OFF_BYTE)
def set_led(self, on):
if type(on) is not bool:
raise ValueError('LED request must be bool')
if self.shadow is None:
raise OSError('Unknown state: inspect wiring, then use s to resynchronise')
value = self.shadow & ~LED_MASK if on else self.shadow | LED_MASK
return self._write((value | INPUT_MASK) & ~UNUSED_MASK)
def observe(self):
"""Read actual pin levels, never replace the shadow with the received byte."""
if self.shadow is None:
raise OSError('Unknown state: observation blocked until s succeeds')
previous = self.shadow
self.shadow = None
data = self.bus.readfrom(self.address, 1)
if type(data) not in (bytes, bytearray) or len(data) != 1:
raise OSError('Incomplete one-byte port read; command state unknown')
sample = data[0]
if (sample ^ previous) & OUTPUT_MASK:
raise OSError('Output pins contradict command: sample 0x%02X; state unknown' % sample)
self.shadow = previous
return sample
def show(port, sample, emit):
# Called only immediately after a successful read in this console iteration.
emit('ACKed command 0x%02X; pin sample 0x%02X; P0 %s; P4 request %s' %
(port.shadow, sample, 'PRESSED' if button_pressed(sample) else 'released',
'LOW / LED on' if not port.shadow & LED_MASK else 'released / LED off'))
def run_console(bus, clock=time, read_line=input, emit=print):
"""Interactive one-owner fixture; no event count, debounce or safety claim."""
port = ShadowPort(bus, clock)
try:
emit('PCF8574N: 1=LED on, 0=off, ?=fresh pins, s=resynchronise off, q=quit')
try:
port.synchronise_off()
emit('Startup 0x11 acknowledged; LED-off request, not a measured result')
except OSError as exc:
emit('FAULT / UNKNOWN: %s' % exc)
while True:
command = read_line('pcf> ').strip()
if command == 'q':
break
try:
if command == 's':
port.synchronise_off()
elif command == '1':
port.set_led(True)
elif command == '0':
port.set_led(False)
elif command != '?':
emit('Use 1, 0, ?, s or q; no port write for this input')
continue
show(port, port.observe(), emit)
except OSError as exc:
emit('FAULT / UNKNOWN: %s' % exc)
emit('Inspect with power off; after correction, s releases P0/P4 and holds unused pins LOW')
except (KeyboardInterrupt, EOFError):
emit('Console interrupted; attempting one LED-off request')
finally:
# One bounded attempt. Failed communication cannot guarantee an off LED.
try:
port.synchronise_off()
emit('Exit 0x11 acknowledged; power off before rewiring')
except OSError as exc:
emit('Exit release unconfirmed: %s; remove power before touching wiring' % exc)
def main():
from machine import Pin, SoftI2C
bus = SoftI2C(sda=Pin(0), scl=Pin(1), freq=100000, timeout=50000)
run_console(bus)
if __name__ == '__main__':
main()
The driver sends exactly one data byte directly after the address. It deliberately does not call a register-addressed write function. A successful observe() returns one current pin sample; it never assigns that sample to shadow. Every output update preserves P0 at 1 and the unused bits at 0. Only P4 changes.
The 10 ms pause after a write is an application settling pause, not a measured timing guarantee or a button debounce algorithm. Debouncing means distinguishing one intended press from the rapid contact transitions of a mechanical switch. Here observations are manually requested and may catch a transition; the program makes no press-count or minimum-pulse-capture claim.
A negative acknowledgement, NACK, short transfer, interrupted transaction or bus failure can leave the controller uncertain. The chip might have accepted its byte before the host noticed an error. The driver therefore clears its acknowledged shadow before starting a transaction and restores it only after a complete expected result. It never reports the old command as successfully retained following an uncertain write.
When unknown, LED updates and observations are blocked. After inspecting the circuit with power absent, s explicitly writes 0x11, requesting off and restoring the pin policy. This is a whole-port write, not a read of an output-latch register. It is suitable for this one-LED fixture; resetting a real actuator port could have consequences requiring a different recovery design.
A fresh read also checks whether any of the seven output bits contradicts its requested logic level, including the unused LOW outputs. For example, an expander reset while the shadow requests LOW can appear as a HIGH pin and invalidate the state. A matching bit does not prove the latch contents, LED current, absence of resets or correctness of the entire system; external circuitry can force a matching voltage. The shadow is trustworthy only as acknowledged intent under our single-owner assumptions. Another program, interrupt handler or controller must not write this device independently.
On normal quit, end of input or keyboard interruption, the console makes one off request. If communication fails, it prints that release is unconfirmed. An I²C command cannot guarantee an off LED through a broken connection. No restart loop silently rewrites the port. Disconnect power for rewiring; do not pull only the chip’s supply wire while SDA/SCL remain powered, because those paths can feed an unpowered device. If a full chip reset is required, use the manufacturer’s power-on-reset supply sequencing, not a guessed brief brownout.
?. Record the command byte, sample byte, LED observation and, if available, actual P0/P4 voltages. Treat a contradicting sample as a fault rather than adjusting the software to hide it.?, then 1. In the ideal logic model the first sample is 0x10 and the second is 0x00, while the acknowledged command becomes 0x01. Release S1 and enter ?; the model predicts sample 0x01. Compare with your own observations. Repeat several times without calling these predictions measurements.0, then q. Expect acknowledged off requests if communication works. Remove USB before changing any connection.The delivered host tests execute the actual driver and console with injected interfaces. They include a held-button sequence, every 8-command LED sequence, all 256 sample bytes, errors before and after a write’s side effect, incomplete reads, reset contradictions and cleanup paths. These tests establish application behavior under those model assumptions. They are separate from a MicroPython target build, real bus waveforms and physical switch/LED results. Record actual observations in the blank sheet; none have been prefilled.
Two systems return pin byte 0x00. In the first, the intended command is 0x01 and the button is held. In the second, the command is 0x00 and the button has been released. Can another read alone tell the program which system it has?
No. Both have P0 physically LOW and P4 LOW, with unused outputs LOW. The same observation is consistent with two different latch histories. A read cannot recover information that the input state conceals. Maintain the intended write bits separately; if that history is uncertain, use a reviewed whole-port synchronization for this fixture. General equipment may need independent feedback and a more careful recovery policy.
Continue with the interactive circuit lessons. For the next experiment, keep the same one-button fixture and investigate which observations change when you alter polling intervals. Do not turn this lesson into an interrupt event counter: a different experiment must account for contact bounce and the chip’s interrupt-clearing behavior.