Supplier photo of an assembled radio kit with a purple circuit board, telescopic antenna and clear case.
Supplier photo
Temu affiliate offer · Project idea

Build a radio

Soldering required. Check the supplied parts and board revision.

View kit on Temu

Tricky Circuit may earn a commission through these links. Product ideas have not been bench-tested by Tricky Circuit and are not verified lesson parts lists.

SPS30: Fan Cleaning Is Not a Fresh Particle Measurement

Particle sensing · original RP2040 Pico · MicroPython 1.26.0

A particle value can stay perfectly steady while the instrument stops updating it. Connect the exact Sensirion SPS30, read complete checked packets, and make the deliberate gap during fan cleaning visible instead of filling it with copies of the last number.

1. The experiment and its boundary

Particulate matter (PM) means suspended particles. The SPS30 uses optical scattering and a fan to sample air; PM2.5 is a reported particle mass-concentration channel, not a gas measurement. This lesson compares a naive display that repeats a cached value with a finite acquisition session that withholds particle results during startup, a commanded cleaning interval and a deliberate restart. A gap is a useful result.

Use the Sensirion SPS30 module, article number 1-101638-10, not another white particle-sensor enclosure. Its product label and connector must match the manufacturer drawing. The program also reads product type 00080000 and requires sensor firmware major 2, minor at least 2. Minor-version compatibility is documented; a future major version is left unqualified. A product string is an additional check, not authentication of a physical module.

The controller is an original Raspberry Pi Pico with RP2040, running the selected official RPI_PICO MicroPython 1.26.0 firmware. The lesson provides host-tested software and an independently reviewable proposed circuit. No target execution, physical particle experiment or calibrated air-quality result is claimed. Related electronics lessons explain the electrical foundations.

2. Power and components

Use one Universal Serial Bus (USB) connection to power the Pico. Its VBUS header, physical pin 40, carries USB bus power to the sensor. The SPS30 needs 4.5–5.5 volts (V); its table specifies up to 65 milliamperes (mA) during measurement and up to 80 mA during the first 200 milliseconds (ms) of fan startup. Supply current is not just a logic-chip load. The Pico’s 3.3 V output powers only the two bus pull-up resistors, not the fan module. Leave Pico VSYS pin 39 unused and add no separate positive supply.

Measure the actual voltage at SPS30 pins 1 and 5, including startup, before treating this wiring as qualified. USB source capability, cable loss and connectors matter; nominal USB 5 V does not guarantee a compliant sensor voltage. Keep the metal shielding free of an external earth or chassis connection. It is internally connected to sensor ground; an unintended shield-current path can damage the module. Mount it without blocking its air inlet or outlet.

Part Exact choice or required value Job
U1 Sensirion SPS30, 1-101638-10 Complete optical particle-sensing module; do not open it.
Controller Original RP2040 Pico, MicroPython 1.26.0 Sole bus controller and finite console session.
Harness JST ZHR-5 housing, five SZH-002T-P0.5 contacts, suitable 26–28 American Wire Gauge (AWG) wire; total bus length below 10 cm Numbered sensor connections; crimp and continuity-check before use.
R1, R2 10 kilo-ohms (kΩ), ±1%, one per signal Pull data and clock up to the Pico 3.3 V rail.
C1 100 nanofarads (nF), ceramic, rated at least 10 V Local high-frequency supply bypass.
C2 10 microfarads (µF), rated at least 10 V; observe polarity if electrolytic Local bulk bypass; not a substitute for a capable source.

Sensirion specifies JST ZHR-5 as the mating housing. The schematic below is a numbered functional net map, not a perspective view of the plug. Find contact 1 from the SPS30 drawing; looking into the opposite mating face reverses the apparent order. Never infer pin assignments from purchased cable colours.

3. Trace every connection

Inter-Integrated Circuit (I²C) is the two-wire serial bus here. Serial data (SDA) and serial clock (SCL) use open-drain outputs: a device pulls the wire low, and a resistor restores high. General-purpose input/output (GPIO) names such as GP0 identify Pico signals; physical pin numbers identify header locations. Ground (GND) is the shared return, not a second supply.

Original Pico VBUS pin 40 supplies SPS30 VDD pin 1, with parallel 100 nF and 10 µF bypass to ground. GP0 pin 1 connects to sensor SDA pin 2 and GP1 pin 2 to SCL pin 3. Separate 10 kΩ pull-ups reach 3V3_OUT pin 36. Sensor SEL pin 4 and GND pin 5 connect to Pico GND pin 38; the data pull-up crosses clock with a non-junction bridge.
Original functional circuit. Every sensor contact is used; the bus is pulled to 3.3 V while the module is powered at 5 V. Open full-size drawing · Download editable Scalable Vector Graphics (SVG) (ZIP; file: tricky-circuit-campaigns93-98/batch-010/packages/pico-sps30-cleaning-validity/sps30-functional.svg).
From To Check with power removed
Pico VBUS, physical 40 SPS30 VDD supply, contact 1; C1 and C2 positive supply ends No connection to the 3.3 V pull-up rail.
Pico GND, physical 38 SPS30 GND contact 5, interface-select SEL contact 4, both bypass returns SEL must already be low when sensor power arrives.
Pico GP0, physical 1 SPS30 SDA contact 2 and R1 signal end R1’s other end is 3V3_OUT, physical 36.
Pico GP1, physical 2 SPS30 SCL contact 3 and R2 signal end R2’s other end is the same 3.3 V rail.

The SPS30 accepts a high input from 2.31 V upward; its specified low output reaches at most 0.4 V in the electrical table. The separate 5 V supply therefore does not require pulling its I²C wires to 5 V. Do not add a carrier with hidden 5 V pull-ups. The requested rate is 100 kilohertz (kHz), the SPS30 maximum; it does not use clock stretching. Short cables still need loaded voltage and rise-time checks. For longer wiring Sensirion recommends the Universal Asynchronous Receiver Transmitter (UART) interface; that would be a different circuit and program.

4. Small electrical calculations, limited conclusions

With a nominal 3.3 V rail, one 10 kΩ pull-up draws about 0.33 mA while its line is low; two simultaneous low lines draw about 0.66 mA. At a 9.9 kΩ resistor minimum the single-branch estimate is 0.333 mA. These calculations do not include rail tolerance or establish a guaranteed output drive test.

I_pullup ≈ 3.3 V / 10,000 Ω = 0.330 mA
Rise time (30–70%) ≈ 0.8473 × R_pullup × C_bus
At 10 kΩ and an assumed 100 pF: rise time ≈ 847 ns
For a 1000 ns Standard-mode limit: C_bus ≈ 118 pF nominal
Sensor startup power budget ≈ 5 V × 80 mA = 0.40 W

Picofarads (pF) measure capacitance, nanoseconds (ns) measure time, and watts (W) measure power. The bus capacitance is an assumption until measured; probes add capacitance too. Use a suitable high-impedance oscilloscope to check actual high/low levels and rise times with the complete harness attached. A digital multimeter (DMM) is useful for steady supply and continuity checks but does not establish those edges. The 0.40 W is a supply budget at the stated nominal voltage, not measured dissipation or a qualification of the USB port.

5. What a checked packet does establish

The seven-bit bus address is 0x69. Commands are two bytes, most-significant byte first. Data travels as two-byte words, each followed by a cyclic redundancy check (CRC) byte. This CRC-8 uses polynomial 0x31, initial value 0xFF, no reflection and no final inversion; BE EF gives 92. Command bytes do not receive an extra data CRC. The program verifies every returned word, including status and identification, and rejects short acknowledged writes.

Start command 0x0010 receives data 03 00 plus their CRC, selecting big-endian 32-bit floating-point results. Read-values command 0x0300 returns 60 wire bytes: 40 payload bytes plus 20 CRC bytes. The ten values are four mass concentrations, five number concentrations and typical particle size. Mass uses micrograms per cubic metre (µg/m³); number uses particles per cubic centimetre (#/cm³); size uses micrometres (µm). These are distinct quantities, not interchangeable pollution scores.

The data-ready command 0x0202 distinguishes 0000 from 0001. Reading measured values clears that flag. Normal updates occur roughly once per second; reading faster does not create more measurements. A checked packet and a new-ready response support an observation of the documented protocol. The sensor supplies no measurement timestamp or sequence counter in this packet. Identical adjacent numeric values can be legitimate, and the code does not call them duplicates.

Device status 0xD206 requires firmware 2.2 or later in the reviewed major-2 family. The known mask is 0x00200030: bit 21 SPEED, bit 5 LASER and bit 4 FAN. Reserved bits may be nonzero and are ignored. Any known warning/error terminates this experiment; it is not automatically proof of a broken module. SPEED is not checked during the first three startup seconds or cleaning. FAN is sticky until a clear/reset, whereas SPEED and LASER may clear when conditions recover. The program does not clear a known warning and carry on.

Mass results at or above 1000 µg/m³ and number results at or above 3000 #/cm³ are withheld under this lesson’s conservative range policy. Those are documented range endpoints, not a saturation-status bit. All values must be finite and nonnegative. Typical size has no invented upper saturation threshold in this code. These checks cannot verify calibration, particle composition, inlet condition or physiological risk.

6. One deliberate cleaning cycle

The sensor does not expose a cleaning-busy flag in this interface. Acknowledging 0x5607 establishes that the manual cleaning command was accepted, not that a later fan operation has been physically verified. Sensirion describes a ten-second cleaning procedure during which measurement values are not updated. The application enters CLEANING_HOLD immediately and reads no particle frames there.

The normal automatic interval is 604800 seconds, with ±3% tolerance, and writing it persists across power cycles. This experiment never changes it. Before the timed session, the program stops measurement, identifies the module and checks status, then deliberately sends software reset 0xD304. The documented reset returns the power-reset state and gives this experiment a new cleaning-counter epoch. It waits 100 ms, reads the retained interval and permits only zero (automatic cleaning disabled) or a value above 120 seconds. The clock guard includes the entire preparation, rejecting more than 2 observed seconds before the session begins. Even 121 seconds reduced by 3% exceeds the 90-second session plus that accepted preparation and short serviced-call margin. A Pico-only rerun without that successful reset would not establish this margin.

State Application action Particle output
STARTUP, first 35 s Poll, CRC-check and discard ready frames. None. The 35 s hold is policy, not a universal stabilization guarantee.
ACQUIRE / WAIT Check status before/after a ready frame; fault after more than 3 s without a new frame. READ rows only; boundary frames are discarded.
At approximately 40 s Send exactly one manual cleaning command. CLEANING_HOLD, no particle reads for at least 10 observed seconds.
REQUALIFY Stop, check status, restart and drain for another 35 s. None; do not reuse the pre-clean frame.
At 90 s / FAULT Close or raise; always attempt a final stop. Earlier printed rows remain historical observations.
Constructed 90-second host-model timeline shows startup to 35 seconds, an acquisition window, commanded cleaning near 40–50 seconds, requalification to about 85 seconds, and another short acquisition window. Red crosses show cached synthetic PM2.5 copies during cleaning; the delivered program's blue READ points leave that gap empty.
Constructed session processed by the actual delivered program and a host bus model. The repeated 2.0 µg/m³ value is synthetic; no particle data were measured. Open full-size timeline · Download editable SVG (ZIP; file: tricky-circuit-campaigns93-98/batch-010/packages/pico-sps30-cleaning-validity/sps30-cleaning-timeline.svg).

The datasheet’s typical startup figures depend on particle number concentration: 8, 16 and 30 seconds for its specified concentration bands. They are not a maximum for every atmosphere. The application hold is deliberately explicit; qualify an appropriate physical settling procedure before relying on a particular installation. An uncommanded automatic clean, a sensor power reset or another bus controller is outside the clean-history guarantee. A gap/status check is not a hidden-event recorder.

7. Complete finite program

Save pico-sps30-cleaning-validity.py (ZIP; file: tricky-circuit-campaigns93-98/batch-010/packages/pico-sps30-cleaning-validity/pico-sps30-cleaning-validity.py) on the Pico, without renaming it to main.py. Start from a power-cycled, awake module. In the read–evaluate–print loop (REPL), execute this block once; the explicit call runs one session and does not install an automatic startup task.

scope = {"__name__": "lesson096"}
exec(open("pico-sps30-cleaning-validity.py").read(), scope)
scope["main"]()

The RP2 port supports a 20000-microsecond (µs) hardware-I²C timeout. That is a low-level transfer limit, not an exact end-to-end deadline for a multi-command sample or a hardware fan cutoff. Removing the weak pin pulls preserves the I²C alternate function; the two explicit external resistors define the idle level.

"""SPS30 / original Pico, MicroPython 1.26.0. Host tests are not hardware tests.
No NVM interval writes, status clearing, sleep, file writes or auto-start.
"""
import struct
from math import isfinite

ADDRESS = 0x69
KNOWN_STATUS = (1 << 21) | (1 << 5) | (1 << 4)
STARTUP_MS = 35000
CLEAN_AT_MS = 40000
CLEAN_HOLD_MS = 10000
END_MS = 90000
FIELDS = ('pm1_ug_m3', 'pm2_5_ug_m3', 'pm4_ug_m3', 'pm10_ug_m3',
          'n0_5_per_cm3', 'n1_per_cm3', 'n2_5_per_cm3',
          'n4_per_cm3', 'n10_per_cm3', 'typical_size_um')


def crc8(two):
    if len(two) != 2:
        raise ValueError('CRC_WORD_LENGTH')
    crc = 255
    for byte in two:
        crc ^= byte
        for _ in range(8):
            crc = ((crc << 1) ^ (0x31 if crc & 128 else 0)) & 255
    return crc


def unpack_words(wire, length):
    if len(wire) != length * 3 // 2 or length % 2:
        raise ValueError('PACKET_LENGTH')
    out = bytearray()
    for i in range(0, len(wire), 3):
        word = wire[i:i + 2]
        if crc8(word) != wire[i + 2]:
            raise ValueError('CRC')
        out.extend(word)
    return bytes(out)


def decode_measurement(raw):
    if len(raw) != 40:
        raise ValueError('MEASUREMENT_LENGTH')
    values = struct.unpack('>10f', raw)
    for i, value in enumerate(values):
        # Range endpoints are intentionally excluded, not called a saturation flag.
        upper = 1000 if i < 4 else (3000 if i < 9 else float('inf'))
        if not isfinite(value) or value < 0 or value >= upper:
            raise ValueError('RANGE_OR_NONFINITE')
    return dict(zip(FIELDS, values))


class Clock:
    def __init__(self, read, diff):
        self.read, self.diff = read, diff
        self.last = read()
        self.elapsed = 0
        self.failed = False

    def now(self):
        if self.failed:
            raise ValueError('CLOCK_FAULT')
        raw = self.read()
        delta = self.diff(raw, self.last)
        if type(delta) is not int or not 0 <= delta <= 2000:
            self.failed = True
            raise ValueError('CLOCK_FAULT')
        self.last = raw
        self.elapsed += delta
        return self.elapsed


class Sensor:
    def __init__(self, bus, wait_ms):
        self.bus, self.wait_ms = bus, wait_ms

    def command(self, command, payload=b'', delay=0):
        request = bytes((command >> 8, command & 255))
        if payload:
            if len(payload) % 2:
                raise ValueError('PARAMETER_LENGTH')
            for i in range(0, len(payload), 2):
                word = payload[i:i + 2]
                request += word + bytes((crc8(word),))
        if self.bus.writeto(ADDRESS, request) != len(request):
            raise OSError('SHORT_WRITE')
        if delay:
            self.wait_ms(delay)

    def read(self, command, length):
        self.command(command)
        return unpack_words(self.bus.readfrom(ADDRESS, length * 3 // 2), length)

    def status(self):
        raw = self.read(0xD206, 4)
        value = int.from_bytes(raw, 'big')
        if value & KNOWN_STATUS:
            raise ValueError('DEVICE_STATUS_0x%08x' % value)
        return value

    def start(self):
        self.command(0x0010, b'\x03\x00', 20)

    def stop(self):
        self.command(0x0104, delay=20)

    def prepare(self):
        # Caller must power-cycle a sleeping/unresponsive module; no wake guessing.
        self.stop()
        product = self.read(0xD002, 8)
        version = self.read(0xD100, 2)
        if product != b'00080000':
            raise ValueError('PRODUCT')
        if version[0] != 2 or version[1] < 2:
            raise ValueError('FIRMWARE_REQUIRES_2_X_AT_LEAST_2_2')
        before_reset_status = self.status()  # Do not erase a known warning first.
        self.command(0xD304, delay=100)
        interval = int.from_bytes(self.read(0x8004, 4), 'big')
        if interval != 0 and interval <= 120:
            raise ValueError('AUTO_INTERVAL_TOO_SHORT')
        self.status()
        self.start()
        return {'product': product.decode(), 'firmware': tuple(version),
                'auto_interval_s': interval, 'pre_reset_status': before_reset_status}

    def frame(self):
        before = self.status()
        ready = self.read(0x0202, 2)
        if ready not in (b'\x00\x00', b'\x00\x01'):
            raise ValueError('READY_FLAG')
        if ready == b'\x00\x00':
            return None
        values = decode_measurement(self.read(0x0300, 40))
        after = self.status()
        return {'values': values, 'status_before': before, 'status_after': after}


class Session:
    def __init__(self, sensor, clock):
        self.sensor, self.clock = sensor, clock
        self.started = self.clock.now()
        self.phase_start = self.started
        self.state = 'STARTUP'
        self.clean_at = None
        self.last_new = None
        self.seq = 0
        self.failed = False

    def step(self):
        if self.failed:
            raise ValueError('SESSION_INVALID')
        try:
            return self._step()
        except BaseException:
            self.failed = True
            self.state = 'FAULT'
            raise

    def _step(self):
        now = self.clock.now()
        elapsed = now - self.started
        if elapsed >= END_MS:
            self.state = 'CLOSED'
            return {'state': 'CLOSED', 'elapsed_ms': elapsed}
        if self.clean_at is None and elapsed >= CLEAN_AT_MS:
            self.sensor.command(0x5607, delay=5)
            self.clean_at = self.clock.now()
            self.state = 'CLEANING_HOLD'
            self.last_new = None
            return {'state': self.state, 'elapsed_ms': elapsed}
        if self.state == 'CLEANING_HOLD':
            if now - self.clean_at < CLEAN_HOLD_MS:
                return {'state': self.state, 'elapsed_ms': elapsed}
            # Deliberate stop/start instead of accepting a cached pre-clean frame.
            self.sensor.stop()
            self.sensor.status()
            self.sensor.start()
            self.phase_start = self.clock.now()
            self.state = 'REQUALIFY'
            self.last_new = None
            return {'state': self.state, 'elapsed_ms': elapsed}
        frame = self.sensor.frame()
        done = self.clock.now()
        if done - now > 1000:
            raise ValueError('TRANSACTION_SPAN')
        if done - self.started >= END_MS:
            self.state = 'CLOSED'
            return {'state': self.state, 'elapsed_ms': done - self.started}
        if self.state in ('STARTUP', 'REQUALIFY'):
            if done - self.phase_start < STARTUP_MS:
                return {'state': self.state, 'elapsed_ms': done - self.started,
                        'discarded_ready_frame': frame is not None}
            self.state = 'ACQUIRE'
            self.last_new = done
            # Discard this boundary frame; require another ready response next time.
            return {'state': 'ACQUIRE', 'elapsed_ms': done - self.started,
                    'discarded_ready_frame': frame is not None}
        if frame is None:
            if done - self.last_new > 3000:
                raise ValueError('STALE_NO_NEW_FRAME')
            return {'state': 'WAIT', 'elapsed_ms': done - self.started}
        self.last_new = done
        self.seq += 1
        frame.update({'state': 'READ', 'sequence': self.seq,
                      'received_elapsed_ms': done - self.started})
        return frame


def run(sensor, read_ticks, ticks_diff, wait_ms, emit=print):
    """Finite console session. Any failure terminates; cleanup always attempts STOP.
    A printed row is an earlier completed observation, not ongoing sensor health.
    """
    try:
        clock = Clock(read_ticks, ticks_diff)
        info = sensor.prepare()
        clock.now()  # Include aggregate preparation latency in the service-gap guard.
        session = Session(sensor, clock)
        emit({'state': 'IDENTIFIED', 'device': info})
        for _ in range(600):  # Frozen clock still terminates by finite poll budget.
            row = session.step()
            emit(row)
            if row['state'] == 'CLOSED':
                return {'state': 'CLOSED', 'samples': session.seq}
            wait_ms(250)
        raise ValueError('POLL_BUDGET')
    finally:
        sensor.stop()  # Failure is propagated; it does not certify a stopped fan.


def main():
    import time
    from machine import Pin, I2C
    sda, scl = Pin(0), Pin(1)
    bus = I2C(0, sda=sda, scl=scl, freq=100000, timeout=20000)
    # RP2 Pin.init(pull=None) leaves the alternate function but removes weak pulls.
    sda.init(pull=None)
    scl.init(pull=None)
    return run(Sensor(bus, time.sleep_ms), time.ticks_ms,
               time.ticks_diff, time.sleep_ms)

A READ row contains all ten values, checked status words, a host sequence and received_elapsed_ms. The timestamp is after completed communication, not when a particle passed the laser. WAIT contains no copied particle value. Clock observations reject a backward difference or an observed gap above 2000 ms, and the 600-poll budget ends even a frozen-clock model. A whole unobserved modular tick wrap can alias the same value; continuous short-session servicing is an assumption. Software cannot stop the fan while a processor or bus call is hung.

Any session exception is sticky and terminates this run. The outer cleanup attempts stop even after startup, console or keyboard interruption. If the bus is broken, the stop may fail; its exception is propagated and the fan state is unknown. A successful printed CLOSED row describes an earlier completed interval. A later console/final-stop failure can still prevent a successful function return; printing does not certify the present electrical state. Remove USB power after the run when finished; wire faults are prepared only with power off.

8. Reproducible observations without manufactured dust

  1. With USB removed, verify all five connector contacts, both pull-ups and capacitor polarity against the numbered net table. Verify the shielding is not connected to another chassis.
  2. Connect USB only. Check the actual sensor supply during fan startup and the loaded bus levels/rise times. If any electrical check fails, stop before the console experiment.
  3. Run once in ordinary room air, with the inlet and outlet unobstructed. Record the label, firmware, ambient conditions and chosen position in the blank observations sheet (ZIP; file: tricky-circuit-campaigns93-98/batch-010/packages/pico-sps30-cleaning-validity/observations-blank.csv). Do not introduce smoke, sprays or a deliberate dust cloud.
  4. Observe STARTUP without particle output. After its hold, distinguish WAIT from READ. Keep original rows; do not replace WAIT with the preceding concentration.
  5. Near 40 seconds, note the commanded cleaning state and possible sound change without touching or blocking the fan. During its hold there must be no READ row. The subsequent stop/restart and REQUALIFY are intentional.
  6. Compare the two acquisition windows as observations from different operating episodes. The room may have changed; equality does not prove a cached packet, and inequality does not quantify cleaning effectiveness.
  7. For a reversible fault trial, remove power, disconnect SDA, restore power and run again. Expect communication failure and no invented sample. Remove power before reconnecting. Do not short signals or mechanically block the fan to create a status bit.
  8. Use the supplied host test archive (ZIP; file: tricky-circuit-campaigns93-98/batch-010/packages/pico-sps30-cleaning-validity/host-tests.zip) to inject CRC failures, missing ready responses and warning bits without abusing hardware. Extract it and run python3 test_driver.py in that directory. These tests exercise the actual program, not the sensor.

The worksheet is empty by design. Copy raw console rows alongside any manually transcribed observations; do not turn a host-model result into a bench record. Timing, concentration, supply and waveform measurements remain to be performed on the actual fixture.

9. What this experiment cannot decide

CRC protects transport words, not the sensing physics. A valid reply can coexist with a blocked inlet, unsuitable mounting, unobserved power loss or incorrect calibration. The status register has delayed checks and no universal validity bit; checking before and after a frame is not an atomic measurement/status snapshot. A module reset between checks may be missed. The program’s stop/restart policy reduces reuse of known pre-clean data but does not authenticate internal history.

The best-performance temperature and humidity ranges in the reviewed datasheet are 10–40 °C and 20–80% relative humidity. Its stress table is not an operating-quality promise, and neither the 5.5 V stress limit nor a maximum pin-current rating permits arbitrary wiring. This room-air experiment makes no exposure, health-threshold, certified-monitor or particle-accuracy claim. A mass-channel number is not a substitute for carbon dioxide or another gas measurement.

10. Challenge

A display updates four times per second, shows PM2.5 = 2.0 throughout cleaning, and reports no SPEED warning. Has it demonstrated fresh measurements or a successful fan test?

Show answer

No. The display can be copying a cached value; Sensirion says values are not updated during cleaning. SPEED is not checked during that procedure. The useful record is the explicit cleaning gap, followed by a separate requalification/acquisition episode. Even a new-ready CRC-checked reply has a host receipt time rather than a physical measurement timestamp, and it does not prove calibration or continuous fan health.

11. Primary references

Download the complete reader files (ZIP): full programs, blank observation worksheets, original editable SVG/PNG diagrams and selected support files for pico-sps30-cleaning-validity. Inline main programs remain complete; this download is supplementary.

📡Broadcast the signal — amplify the connection.

Leave a Reply

Temu affiliate offers

Find your next electronics project

Explore these do-it-yourself project kits. Check the selected option, included parts and instructions on each offer.

Tricky Circuit may earn a commission if you buy through these Temu affiliate links. These are product ideas; they have not been bench-tested by Tricky Circuit and are not verified lesson parts lists.

Supplier photo of an assembled infinity mirror light cube illuminated on a dark background.

Supplier product photo

Build an infinity light cube

Listing specifies 5-volt direct current. Soldering required; battery and glue not included.

View kit on Temu

Supplier photo of a breadboard, jumper wires and assorted electronic components in a plastic storage case.

Supplier product photo

Try breadboard experiments

Breadboard, wires and assorted parts. Arduino controller board not included.

View kit on Temu

Supplier photo of an assembled blue surface-mount soldering practice board with a circular ring of small lights.

Supplier product photo

Solder a chasing-light board

Surface-mount soldering practice. Battery and battery holder not included.

View kit on Temu