Here's a step by step guide to understanding the code for this project. I personally find it helpful for myself when learning new things to explain what I am doing to an audience.

Step 1: Handling Secrets Securely

Hardcoding credentials directly into main application files makes sharing code messy. Following standard MicroPython conventions, I created a standalone secrets.py file stored locally on the board's filesystem:

# secrets.pyADAFRUIT_IO_USERNAME = "your_username_here"
ADAFRUIT_IO_KEY      = "your_aio_key_here"

The main script imports these credentials to construct the Adafruit IO topic hierarchy (username/feeds/feed-name) without exposing sensitive API keys.

Step 2: The Firmware Breakdown

Here is how each section of the project functions, from hardware bring-up to cloud communication.

1. Imports and Configuration

import gc
import time
import network
from machine import I2C, Pin, SPI
from secrets import ADAFRUIT_IO_KEY, ADAFRUIT_IO_USERNAME
from umqtt.simple import MQTTClient
import ahtx0

# MQTT Broker Details
MQTT_HOST = "io.adafruit.com"
MQTT_PORT = 1883

# Adafruit IO Feeds
TEMP_TOPIC = f"{ADAFRUIT_IO_USERNAME}/feeds/temperature"
HUMIDITY_TOPIC = f"{ADAFRUIT_IO_USERNAME}/feeds/humidity"
LED_TOPIC = f"{ADAFRUIT_IO_USERNAME}/feeds/led"
BUZZER_TOPIC = f"{ADAFRUIT_IO_USERNAME}/feeds/beep"

PUBLISH_INTERVAL_MS = 30000
import gc
import time
import network
from machine import I2C, Pin, SPI
from secrets import ADAFRUIT_IO_KEY, ADAFRUIT_IO_USERNAME
from umqtt.simple import MQTTClient
import ahtx0

# MQTT Broker Details
MQTT_HOST = "io.adafruit.com"
MQTT_PORT = 1883

# Adafruit IO Feeds
TEMP_TOPIC = f"{ADAFRUIT_IO_USERNAME}/feeds/temperature"
HUMIDITY_TOPIC = f"{ADAFRUIT_IO_USERNAME}/feeds/humidity"
LED_TOPIC = f"{ADAFRUIT_IO_USERNAME}/feeds/led"
BUZZER_TOPIC = f"{ADAFRUIT_IO_USERNAME}/feeds/beep"

PUBLISH_INTERVAL_MS = 30000

Module selection: machine exposes raw hardware peripherals (Pin, SPI, I2C), while network interfaces with the onboard W5500 MAC/PHY. umqtt.simple provides a low-overhead MQTT client, and ahtx0 handles register communication and calibration for the temperature/humidity sensor.

Topic structure: Adafruit IO expects MQTT topics formatted as username/feeds/feed-name. Defining them dynamically via string interpolation keeps the code clean.

Interval: PUBLISH_INTERVAL_MS is set to 30, 000 ms (30 seconds) to respect the Adafruit IO free-tier rate limits.

2. Bringing Up Ethernet via SPI and DHCP

# Configure SPI 2 for the internal W5500 controller
spi = SPI(2, baudrate=8_000_000)
cs = Pin("PB12", Pin.OUT)
rst = Pin("PD9", Pin.OUT)

# Initialize network driver
nic = network.WIZNET5K(spi, cs, rst)
nic.active(True)
nic.ifconfig("dhcp")

print("Waiting for DHCP lease...")
deadline = time.ticks_add(time.ticks_ms(), 30000)

while nic.ifconfig()[0] == "0.0.0.0":
    if time.ticks_diff(deadline, time.ticks_ms()) <= 0:
        raise RuntimeError("DHCP timeout")
    time.sleep_ms(500)

print("Ethernet Link UP! IP Address:", nic.ifconfig()[0]) 

Internal SPI: The onboard WIZnet chip is physically wired to SPI 2, requiring PB12 for Chip Select and PD9 for Reset.

DHCP lease: Passing "dhcp" to nic.ifconfig() commands the board to request an automatic IP, subnet mask, gateway, and DNS server from the local router.

Non-blocking wait: While negotiating, nic.ifconfig()[0] returns "0.0.0.0". Using time.ticks_add() and time.ticks_diff() establishes a clean 30-second timeout rather than locking up Thonny indefinitely if an Ethernet cable is unplugged.

3. Initializing I2C and Actuator GPIOs

# Initialize I2C bus and environmental sensor
i2c = I2C(1)
print("Detected I2C devices:", [hex(addr) for addr in i2c.scan()])
sensor = ahtx0.AHT20(i2c)

# Configure output pins for feedback
led    = Pin("PD14", Pin.OUT)
buzzer = Pin("PD15", Pin.OUT)

led.off()
buzzer.off()

I2C scanning:i2c.scan() pings the bus and verifies the sensor replies with an ACK at its standard hardware address (0x38). Passing that bus instance into ahtx0.AHT20(i2c) prepares the sensor for data sampling.

Safe defaults: Initializing PD14 (LED) and PD15 (buzzer) followed immediately by .off() ensures the actuators stay silent and off while the microcontroller finishes booting.

4. Handling Inbound Cloud Messages (MQTT Callback)

def mqtt_callback(topic, message):
    topic_str = topic.decode()
    msg_str = message.decode().strip().upper()
    print(f"Received -> [{topic_str}]: {msg_str}")

    if topic_str == LED_TOPIC:
        if msg_str in ("ON", "1"):
            led.on()
            print("Action: LED ON")
        elif msg_str in ("OFF", "0"):
            led.off()
            print("Action: LED OFF")

    elif topic_str == BUZZER_TOPIC:
        if msg_str in ("ON", "1"):
            buzzer.on()
            print("Action: BUZZER ON")
        elif msg_str in ("OFF", "0"):
            buzzer.off()
            print("Action: BUZZER OFF")

Byte decoding: MQTT packets arrive as raw byte arrays (like b'ON'). Calling .decode() converts them into Python strings for straightforward conditional matching.

Asynchronous dispatch: This function remains dormant until a packet matching a subscribed feed arrives from the broker, instantly flipping the corresponding GPIO output.

5. Connecting and Subscribing

client_id = "w55mh32-mqtt-node"

client = MQTTClient(
    client_id,
    MQTT_HOST,
    MQTT_PORT,
    ADAFRUIT_IO_USERNAME,
    ADAFRUIT_IO_KEY,
)

client.set_callback(mqtt_callback)
client.connect()

# Listen for incoming toggle events from Adafruit IO
client.subscribe(LED_TOPIC)
client.subscribe(BUZZER_TOPIC)

print("Connected to MQTT Broker and subscribed to control feeds.")

MQTTClient(...): Instantiates the client with our server details and authentication keys.

Socket connection:client.connect() opens the underlying TCP socket over Ethernet directly to Adafruit IO on port 1883.

Feed registration:client.subscribe() registers our intent to receive any messages published to our dashboard's button feeds.

6. Reading and Publishing Telemetry

def publish_sensor_data():
    temperature = round(sensor.temperature, 1)
    humidity = round(sensor.relative_humidity, 1)

    print(f"Telemetry -> Temp: {temperature} C | Humidity: {humidity} %")

    client.publish(TEMP_TOPIC, str(temperature))
    client.publish(HUMIDITY_TOPIC, str(humidity))

    print("Sensor data published.") 

Sensor sampling: The driver properties query the I2C registers and convert the raw values into calibrated floating-point numbers.

Data serialization: The numbers are rounded to one decimal place to eliminate noise, cast to strings, and sent via client.publish().

7. Non-Blocking Event Loop

last_publish = time.ticks_ms()

try:
    while True:
        # Check socket for incoming control commands
        client.check_msg()

        # Check if it is time to publish sensor telemetry
        now = time.ticks_ms()
        if time.ticks_diff(now, last_publish) >= PUBLISH_INTERVAL_MS:
            last_publish = now
            publish_sensor_data()
            gc.collect()  # Free fragmented memory

        time.sleep_ms(100)

except KeyboardInterrupt:
    print("Disconnecting...")
    client.disconnect()

Why avoid time.sleep(30)? If the loop halted for 30 seconds, the board would be completely deaf to incoming MQTT dashboard commands during that window. Sleeping for only 100 ms allows client.check_msg() to respond almost instantaneously to cloud button presses.

Memory maintenance: Repeatedly creating string buffers and network sockets in MicroPython can fragment the small heap. Explicitly calling gc.collect() after each publish keeps memory allocations clean and predictable.

Graceful exit: Wrapping the loop in a try / except KeyboardInterrupt block ensures that terminating the script sends a formal MQTT disconnect packet, preventing ghost sessions on the server.

The Result

Setting up an Adafruit IO dashboard with two gauge cards (pointing to temperature and humidity) and two toggle switches (pointing to led and beep) completes the loop.