TRICKY CIRCUITLocal article review · no physical test claimed

ARTICLE 87 · SENSOR EXPERIMENT

Pico TSL2591: Change Range Without Relabelling an Old Reading

The reading changed. Did the light change?

Moving from a dark shelf to a bright desk is a useful light-sensor test. Changing the sensor’s range while leaving it on the same desk is a different test: can the program explain which exposure produced each number? A larger result after increasing gain is expected. An old result accidentally labelled with the new gain is a software error that can look equally convincing.

This experiment uses a TSL2591 carrier and an original Raspberry Pi Pico to collect four finite exposures: low gain for 100 milliseconds (ms), medium gain for 100 ms, medium gain for 200 ms, then low gain for 100 ms again. Gain is amplification before conversion; integration time is the interval during which the sensor accumulates light. Neither setting is a brightness unit. We retain both raw channels, reject clipped comparisons and demand evidence of a new conversion after each setting change.

The outcome is a range-switching experiment, not a calibrated lux meter. Lux describes illumination weighted approximately for human vision. Converting two detector channels into lux requires an appropriate optical model and calibration; those steps are deliberately outside this program. For the underlying ideas, try Light becomes a signal and Turn a voltage into a number.

Choose the carrier, not a guessed chip suffix

Use the Adafruit PID1980 TSL2591 STEMMA QT carrier, an original RP2040 Raspberry Pi Pico, four short jumper wires, soldered headers and a Universal Serial Bus (USB) cable. The software target is the official RPI_PICO MicroPython 1.26.0 build. A lamp with a steady setting, a fixed board support and an opaque card make the optical experiment repeatable. A digital multimeter, which measures voltage, is useful for the initial supply check.

There is an identity limit worth recording before the experiment. The current carrier schematic calls its sensor U1 “TSL2591”; the family’s identification register, a readable device-control byte, returns 0x50. Neither establishes the populated silicon’s full ordering suffix. The current ams OSRAM product page separately lists TSL25911FN, Q65114A0923. This lesson uses Adafruit’s supported PID1980 carrier interface; it does not declare that every shipped PID1980 contains that exact suffix. Record the purchased board revision and any traceable supplier documentation. Do not transfer these connections to an unverified bare chip or substitute the different input/output (I/O) voltage variant on the strength of a similar name.

For the manufacturer’s TSL25911FN reference, normal supply operation is 2.7–3.6 V; the 3.8 V absolute maximum is a damage boundary, not a target. Here the carrier input and Pico signals use 3.3 V. Adafruit explicitly supports powering this carrier from the controller’s 3.3 V supply. The carrier’s regulator introduces some drop, so check its 3Vo output against the supported operating range before collecting data. A reading on a multimeter does not check every transient.

Four wires and one shared supply

Disconnect USB before wiring. GND means ground, the common voltage reference. VIN is the carrier’s power input. The Inter-Integrated Circuit (I²C) bus uses serial clock (SCL) and serial data (SDA); these are shared digital lines with pull-up resistors, not analog detector outputs. An open-drain output either pulls its line LOW or releases it; the pull-up provides HIGH. The program uses I²C controller 0 at 100 kilohertz (kHz), or 100,000 clock cycles per second.

Original Pico terminalPID1980 header terminalPurpose
3V3_OUT, physical pin 36VIN, header pin 1Single 3.3 V power source
GND, physical pin 38GND, header pin 3Power return and logic reference
GP1, physical pin 2SCL, header pin 4I²C clock
GP0, physical pin 1SDA, header pin 5I²C data

The wiring illustration has an editable Scalable Vector Graphics (SVG) original and a Portable Network Graphics (PNG) full-size image.

Four complete connections between an original Pico and Adafruit PID1980 carrier: physical pin 36 to VIN header 1, GND pin 38 to header 3, GP1 physical pin 2 to SCL header 4, and GP0 physical pin 1 to SDA header 5. Carrier 3Vo and INT remain unconnected.
Original carrier-level wiring. The four colored paths are complete external nets; the board blocks do not invent internal chip connections. Editable SVG · Full-size PNG.

Leave 3Vo, header pin 2, and the interrupt output INT, header pin 6, unconnected; the small STEMMA QT sockets are also unused. Do not connect carrier 3Vo back to Pico 3V3_OUT or add a second supply. Do not attach the Pico’s 5 V USB rail to either signal line. The pin numbers in the diagram identify electrical terminals; their drawn spacing is not a physical assembly guide.

The reviewed current carrier drawing includes an AP2112K-3.3 regulator, two 10 microfarad (µF) power capacitors, a 0.1 µF sensor bypass capacitor, and BSS138 level shifting with four 10 kilohm (kΩ) pull-ups. The capacitors support the local supply; pull-ups let the open-drain bus lines return HIGH. The controller-facing pull-ups go to VIN and the sensor-facing pair to the regulated rail. These parts are already on the carrier. Do not add another set of pull-ups for this short single-carrier trial. A nominal 3.3 V divided by 10 kΩ is 0.33 milliampere (mA) per pull-up; a conducting level shifter can involve both sides, so one resistor is not the complete bus-low current budget.

The seven-bit device address is 0x29. The family also responds at read-only address 0x28; seeing both during a scan does not establish two separate sensors. This program addresses only 0x29 and does not connect a second device at either occupied address.

Counts, gain and a ceiling that changes

Two analog-to-digital converters (ADCs) turn accumulated detector charge into integer counts. Channel 0 responds broadly to visible and infrared light, while channel 1 emphasizes infrared. Their difference is not automatically a calibrated visible-light measurement. Keep both channels in every record.

SettingTypical gain ratio, CH0 / CH1Interpretation
LOW1 / 1Starting reference range
MED24.5 / 24.5Actual specified ratios span 22–27
HIGH400 / 400Actual specified ratios span 360–440
MAX9200 / 9900Channel-dependent typical ratios

The integration code 0 means nominal 100 ms; codes 1–5 mean 200–600 ms in 100 ms steps. At 100 ms the maximum count is 36863, rather than the full sixteen-bit range. At longer settings it is 65535. The program marks an exposure clipped if either channel reaches or exceeds its setting’s ceiling. The raw values remain available as evidence, but comparison values become None. A clipped value is not an illumination estimate that can be recovered by dividing it by gain.

For an unclipped record, the optional diagnostic scaling is:

comparison_ch0 = raw_ch0 / (typical_gain_ch0 × integration_steps)
comparison_ch1 = raw_ch1 / (typical_gain_ch1 × integration_steps)
integration_steps = atime_code + 1

This merely places numbers on an assumed LOW/100 ms count scale. For example, synthetic CH0 counts 1000, 24500 and 49000 at LOW/100, MED/100 and MED/200 all scale to 1000. Real results need not agree: gain variation, lamp flicker, optical movement and drift remain. MAX uses different divisors for the two channels. These values are not lux, and typical gains are not calibration coefficients.

Synthetic raw-count bars for LOW 100 ms, MED 100 ms, MED 200 ms and LOW 100 ms, with per-setting ceilings. A separate constructed timing trace shows a new AEN epoch, observed AVALID LOW then HIGH, sequential data read, and rejection at or beyond the 190 ms first-cycle boundary for a 100 ms setting.
Original calculated illustration: unchanged synthetic light and typical gain ratios above; a constructed first-cycle timing example below. No measured counts, bus timings or cross-channel atomicity are claimed. Editable SVG · Full-size PNG.

A valid bit is not a sample counter

The register named ENABLE contains the power-on oscillator control PON and ambient-light sensing enable AEN. We write 0 to disable them and 3 to enable both, leaving interrupts disabled. CONTROL holds gain in bits 5:4 and integration code in bits 2:0; reserved bits stay zero. Normal register commands use 0xA0 | register. STATUS bit 0, called AVALID, indicates that a conversion has completed since AEN was asserted. Reading a HIGH AVALID repeatedly does not prove that each read belongs to a new exposure.

Each acquisition therefore disables and verifies ENABLE, writes and verifies CONTROL, starts a new AEN epoch, and requires actually observing AVALID LOW before accepting HIGH. We do not assume that disabling AEN immediately clears that bit. If the first observation is already HIGH, the conservative result is an error, not a guessed timestamp.

For n = atime_code + 1, the datasheet’s integration step spans 95–105 ms. The code rejects HIGH earlier than 95 × n ms after the later host bound on the enable write, and stops waiting after 105 × n + 20 ms. Twenty milliseconds is our application margin, not a manufacturer guarantee. A fast valid conversion can be rejected because the host does not know the precise instant when AEN took effect; withholding an uncertain result is intentional.

The sensor starts another integration automatically. All data and surrounding configuration checks must therefore finish before 190 × n ms from the earlier host bound on enabling: twice the minimum integration time. A late I²C operation can miss that window and invalidate the run. The four data bytes are read sequentially from command 0xB4. Each channel’s low-byte read protects its own high-byte partner; the two channels have independent shadowing. Four sequential bytes reduce skew, but this is not an atomic channel-pair guarantee.

Host enable-before, enable-after and data-read-end timestamps describe program observations. They are neither exact integration start times nor timestamps from the detector. The first-cycle check relies on correct sensor operation, its specified timing, a continuously running host clock and exclusive bus ownership. An unnoticed reset, another program changing the sensor, or a long pause spanning a complete clock-counter wrap can defeat those assumptions. Do not use these records as synchronized scientific measurements.

The complete finite program

The driver has one sticky fault state: an uncertain bus acknowledgement, wrong identity, short read, register mismatch, missing invalid phase, early/late conversion or invalid host clock ends that session. An acknowledgement means the bus transaction was accepted; it does not establish a correct optical measurement. A BaseException handler also covers a keyboard interrupt. The failure path tries to disable the device and prevents reuse of the object. Correct the cause, stop the program and start a fresh session manually; the program does not silently restart after a sensor power interruption.

A successful exposure is returned only after a disable write and matching readback. If final cleanup cannot confirm disable, the overall run raises an error. Rows already printed cannot be withdrawn: retain the later session-invalid message with them. An acknowledged disable is still not physical proof that the carrier is unpowered. Its regulator and power indicator can remain on while conversion is disabled.

Download the complete Python program · Blank observation worksheet.

"""Pico / MicroPython 1.26.0, Adafruit PID1980 STEMMA QT carrier.
Raw, finite exposures; no lux calibration or physical-performance claim.
A fault invalidates this session. Restart manually after inspecting the cause.
"""

ADDR = 0x29
CMD = 0xA0
ENABLE, CONTROL, ID, STATUS, DATA = 0x00, 0x01, 0x12, 0x13, 0x14
GAIN_NAMES = ("LOW", "MED", "HIGH", "MAX")
# Datasheet typical ratios, not calibration constants. MAX differs by channel.
GAIN_TYP = ((1.0, 1.0), (24.5, 24.5), (400.0, 400.0), (9200.0, 9900.0))


class ExposureError(RuntimeError):
    pass


def setting(gain, atime):
    if type(gain) is not int or not 0 <= gain <= 3:
        raise ValueError("gain must be an integer 0..3")
    if type(atime) is not int or not 0 <= atime <= 5:
        raise ValueError("atime must be an integer 0..5")
    return (gain << 4) | atime


def describe(raw, gain, atime):
    """Keep clipped raw evidence, but withhold comparison values."""
    setting(gain, atime)
    if len(raw) != 4:
        raise ExposureError("SHORT_DATA")
    c0 = raw[0] | (raw[1] << 8)
    c1 = raw[2] | (raw[3] << 8)
    ceiling = 36863 if atime == 0 else 65535
    clipped = c0 >= ceiling or c1 >= ceiling
    ratios = GAIN_TYP[gain]
    scaled = None if clipped else (c0 / (ratios[0] * (atime + 1)),
                                   c1 / (ratios[1] * (atime + 1)))
    return {"ch0": c0, "ch1": c1, "ceiling": ceiling,
            "clipped": clipped, "comparison_counts": scaled,
            "comparison_is_calibrated": False}


class RawExposure:
    def __init__(self, bus, ticks_ms, ticks_diff, sleep_ms):
        # Constructor deliberately performs no I2C operation.
        self.bus, self.ticks, self.diff, self.sleep = bus, ticks_ms, ticks_diff, sleep_ms
        self.last_clock = None
        self.fault = None
        self.closed = False
        self.ready = False
        self.number = 0

    def _fail(self, reason):
        self.fault = self.fault or reason
        raise ExposureError(self.fault)

    def _now(self):
        try:
            now = self.ticks()
            if type(now) is not int:
                self._fail("CLOCK_TYPE")
            if self.last_clock is not None:
                gap = self.diff(now, self.last_clock)
                if type(gap) is not int or gap < 0 or gap > 2000:
                    self._fail("CLOCK_GAP")
            self.last_clock = now
            return now
        except BaseException:
            self.fault = self.fault or "CLOCK_FAULT"
            raise

    def _age(self, now, then):
        age = self.diff(now, then)
        if type(age) is not int or age < 0 or age > 2000:
            self._fail("CLOCK_AGE")
        return age

    def _read(self, register, count=1):
        data = self.bus.readfrom_mem(ADDR, CMD | register, count)
        if len(data) != count:
            self._fail("SHORT_READ")
        return data

    def _write(self, register, value):
        self.bus.writeto_mem(ADDR, CMD | register, bytes((value,)))

    def _expect(self, register, expected):
        if self._read(register)[0] != expected:
            self._fail("REGISTER_MISMATCH")

    def _off(self):
        self._write(ENABLE, 0)
        self._expect(ENABLE, 0)

    def begin(self):
        if self.closed or self.fault or self.ready:
            raise ExposureError("NEW_SESSION_REQUIRED")
        try:
            self._now()
            self._off()
            if self._read(ID)[0] != 0x50:
                self._fail("WRONG_ID")
            self.ready = True
        except BaseException:
            self.fault = self.fault or "BEGIN_FAULT"
            self.close()
            raise

    def exposure(self, gain, atime):
        config = setting(gain, atime)
        if not self.ready or self.closed or self.fault:
            raise ExposureError("NEW_SESSION_REQUIRED")
        steps = atime + 1
        try:
            self._now()
            self._off()
            self._write(CONTROL, config)
            self._expect(CONTROL, config)
            before_enable = self._now()
            self._write(ENABLE, 3)       # PON + AEN, interrupts remain disabled.
            self._expect(ENABLE, 3)
            after_enable = self._now()
            if self._age(after_enable, before_enable) >= 95 * steps:
                self._fail("ENABLE_WINDOW_LOST")
            # Do not assume the old AVALID bit clears just because AEN was LOW.
            first = self._read(STATUS)[0] & 1
            now = self._now()
            if first:
                self._fail("NO_OBSERVED_INVALID_PHASE")
            if self._age(now, before_enable) >= 95 * steps:
                self._fail("INVALID_PHASE_TOO_LATE")
            for _poll in range((105 * steps + 20) // 5 + 2):
                valid = self._read(STATUS)[0] & 1
                now = self._now()
                elapsed = self._age(now, before_enable)
                if elapsed > 105 * steps + 20:
                    self._fail("VALID_TIMEOUT")
                if valid:
                    # after_enable is the later host bound on the enable write.
                    if self._age(now, after_enable) < 95 * steps:
                        self._fail("EARLY_VALID")
                    break
                self.sleep(5)
            else:
                self._fail("POLL_BUDGET")
            self._expect(CONTROL, config)
            self._expect(ENABLE, 3)
            raw = self._read(DATA, 4)     # Per-channel shadow, not pair atomicity.
            data_read_end = self._now()
            self._expect(CONTROL, config)
            self._expect(ENABLE, 3)
            checked_end = self._now()
            if self._age(checked_end, before_enable) >= 190 * steps:
                self._fail("FIRST_CYCLE_WINDOW_LOST")
            # A sample is returned only after acknowledged disable + readback.
            self._off()
            self._now()
            result = describe(raw, gain, atime)
            self.number += 1
            result.update({"exposure": self.number, "gain": GAIN_NAMES[gain],
                           "atime_code": atime, "nominal_ms": 100 * steps,
                           "host_enable_before_ms": before_enable,
                           "host_enable_after_ms": after_enable,
                           "host_read_end_ms": data_read_end,
                           "host_read_elapsed_ms": self._age(data_read_end, before_enable),
                           "freshness": "observed_invalid_then_valid_first_window",
                           "pair_atomic": False})
            return result
        except BaseException:
            self.fault = self.fault or "EXPOSURE_FAULT"
            self.close()
            raise

    def close(self):
        """Best effort only: ACK/readback is not proof of physical power-off."""
        self.closed = True
        self.ready = False
        try:
            self._off()
            return True
        except BaseException:
            self.fault = self.fault or "DISABLE_UNCONFIRMED"
            return False


def run(bus, ticks_ms, ticks_diff, sleep_ms, emit=print):
    reader = RawExposure(bus, ticks_ms, ticks_diff, sleep_ms)
    try:
        reader.begin()
        emit("Raw TSL2591 exposures; comparison values are NOT lux.")
        # A/B/C/A: gain change, integration change, then starting range again.
        for gain, atime in ((0, 0), (1, 0), (1, 1), (0, 0)):
            result = reader.exposure(gain, atime)
            emit(result)
        return reader.number
    finally:
        if not reader.close():
            emit("INVALID SESSION: final disable was not confirmed")
            raise ExposureError("DISABLE_UNCONFIRMED")


def main():
    from machine import I2C, Pin
    from time import ticks_ms, ticks_diff, sleep_ms
    bus = I2C(0, sda=Pin(0), scl=Pin(1), freq=100000, timeout=50000)
    return run(bus, ticks_ms, ticks_diff, sleep_ms)


if __name__ == "__main__":
    main()

Copy the file to the Pico without naming it main.py, then use its read–evaluate–print loop (REPL), the interactive Python console, for one explicit run. The following command executes the complete file once; it is not a persistent startup installation.

with open("pico-tsl2591-range-switching.py") as source:
    program = source.read()
exec(program, {"__name__": "__main__"})

The pinned RP2 I²C implementation supports the selected 50,000-microsecond transaction timeout. A microsecond is one millionth of a second. Timeout support does not guarantee that every Python or bus operation meets the acceptance window. The driver also has a finite poll count, so a clock returning the same value forever cannot create an endless status loop. Tick differences handle an ordinary wrap; gaps above two seconds or backwards observations invalidate the session. A whole unobserved wrap cannot be detected from the counter alone.

Run a range trial you can explain

  1. Inspect with power removed. Confirm the carrier labels, four nets and no extra supply or pull-up board. Record the carrier revision and firmware version. Check that the light-sensitive surface is unobstructed.
  2. Power and check voltage. Plug USB into the Pico only. Measure carrier VIN and 3Vo against its GND. Investigate an out-of-range rail before continuing; do not probe the small sensor pins with a slipping meter tip.
  3. Fix the geometry. Place a stable lamp and the carrier on supports. Do not move either during the four-exposure trial. Keep hands out of the light path. Record lamp mode and nearby daylight.
  4. Run once and keep every row. Expect four exposure numbers, each with two raw counts, the selected range and host timing bounds. A clipped record is allowed diagnostic evidence, but its comparison_counts must be None. An exception ends the run; do not splice later rows into it as if nothing happened.
  5. Compare A/B/C/A. The final LOW/100 observation is a drift check against the first. If those change substantially, the middle comparisons cannot distinguish range effects from changing illumination. Try the same sequence again after stabilizing the setup.
  6. Explore clipping deliberately. With power off before moving wires, leave the electrical circuit unchanged. Change only light or the explicit exposure list. At stronger illumination, check whether either channel reaches its ceiling. Reduce gain or integration for a new exposure; never rescale the old clipped value into a supposed measurement.
  7. Check invalidation. Start a new session with the carrier disconnected while unpowered, then power the Pico and run: a bus error must produce no fabricated record. Reconnect only with power off. Timing and configuration fault cases are covered by the host checks described below; there is no need to short live bus lines.

Do not expect longer integration to eliminate all flicker. Lamp modulation, its phase relative to each exposure and optical drift can still alter counts. If repeatability matters, investigate those effects separately rather than treating agreement with the synthetic bars as a required result.

What has actually been checked

The delivered program has 28 host-executed test groups using fake bus and clock objects. They exercise every gain/time setting, both clipping channels and ceilings, little-endian byte assembly, stale and early valid flags, missing completion, delayed reads, changed registers, short replies, errors before and after write side effects, frozen/backwards/wrapped clocks, and console or keyboard-interrupt cleanup. Separate package checks compare the complete inline program, links, editable figures and blank worksheet. These are checks of the actual source’s decisions, not a sensor simulation validated against hardware.

No Pico firmware was built or run for this article, and no physical carrier, illumination accuracy, current consumption, bus waveform or optical repeatability was measured. The worksheet is intentionally empty. Before adopting the experiment in a logger, qualify the actual carrier identity, rail voltage, bus rise times and finite-window behavior on the exact assembled hardware.

Challenge: can a clipped value be rescued?

At MED/100 ms, CH0 returns 36863 and CH1 returns 12000. Dividing CH0 by the typical gain gives about 1504.6. Is that a usable LOW/100 estimate? What should the next exposure do?

Open the answer

No. CH0 has reached the 100 ms ceiling, so the amount by which the true integrated signal exceeded it is unknown. Keep both raw counts and the clipping flag, withhold both comparison values, then acquire again at lower gain. A fresh LOW/100 exposure is a reasonable next trial. Even an unclipped result is only a raw or typical-ratio comparison, not calibrated lux.

Primary references and limits