Want to know if it's dark? Are the pubs open yet? Is it bedtime? To help answer these pressing questions, you could use an "ambient light sensor". Matt's Tip: For the aforementioned applications, a clock may be more accurate, since ambient light can be affected by climate change, lightning, fires and BBQs, etc.
ALS = Amyotrophic Lateral Sclerosis Ambient Light Sensor
Blurb
There are many intelligent digital ambient light sensor chips and modules available these days. These are all tiny SMD chips which are impossible to solder by hand, so you must buy a ready-made module (at 10x the price of the chip). The digital sensors usually have a 2-wire I²C interface (Inter-Integrated Circuit), with SCL (clock) and SDA (data) connections.
Most sensors contain two phototransistors or photodiodes. A "full spectrum" sensor for visible light plus infrared (IR) light, and an "infrared only" sensor which is mostly sensitive to infrared light. The lux value, as registered by the human eye, can be calculated by removing the IR component from the full spectrum sensor. Some chips do this internally. For others, you must do this in the code, which is not easy - most of the lux calculations I have seen in the [few] github libraries is inconsistent or wrong. Even if the chip calculates the lux value itself, it can still be "inconsistent".
Different light sources produce different ranges of light frequencies, which produce varying sensor readings depending on the characteristics of the phototransistor or photodiode. Halogen lamps, incandescent bulbs, LED lamps, lightening, sunlight and flames all have very different IR components and different light frequency ranges. The "angle of incidence" of the light source also has a significant effect. Mains-powered lights can flash at the A/C mains frequency (50 or 60 times a second) and LED lamps may be turned on/off thousands of times a second. Accurate and consistent lux readings are very difficult to obtain.
The sections below describe some of cheapest and common modules. There's also C++ class libraries which are in many some ways superior to the official libraries (in my opinion :-) Some of these modules contain discontinued chips, which is why you can buy them cheap! There do not seem to be many pre-soldered modules for the latest chips yet - but Mattlabs is working on it.
I recommend this one for price, accuracy and simplicity!
This is one of the cheapest modules (CHF3.90), and so far it seems to be the most accurate when compared to the readings from my low-cost Voltcraft LX-10 lux meter (*). But hold on - maybe my lux meter contains the same chip! It also seems to handle infrared light very well, it successfully ignores it and the readings are barely affected.
It's very easy to program too, once you figure out the Chinglish text in the data sheet. The lux range is 0..120000, the upper limit being affected by the programmable measurement time, see setMeasurementTime(uint mt).
The only drawbacks are that it cannot generate interrupts, and you have to use a timer to determine when the next reading is available. (Although you could set the data register to 0 and wait until it's non-zero when the reading has been taken - but that won't work if the lux reading is actually zero.) The code below contains methods to do that, see the example sketch. There's also a method that tests and displays the actual the conversion times (intuitively called getConversionTimes()), with an example in the sketch that displays the conversion times for all programmable measurement times (this is patched out because it's not fast).
The GY-302 module contains a 3.3V regulator, so you can run it from 5V or 3.3V. This BH1750 chip itself runs on 3.3V. Do NOT run it with 5V if connected to a 3.3V controller, like the Arduino Zero.
// Example sketch for the BH1750 Ambient Light Sensor
// Copyright (C) muman.ch, 2025.09.10
// See the blog post for details
// https://muman.ch/muman/index.htm?muman-light-sensors.htm#muman-bh1750
// email: info@muman.ch
#include <Wire.h>
// This is useful for debug output
#ifdef DEBUG
void LogError(char* msg, char* file, unsigned int line)
{
char buf[200];
char* fname = strrchr(file, '\\');
fname = fname ? fname + 1 : file;
sprintf(buf, "%lu %s %s %u", millis(), msg, fname, line);
Serial.println(buf);
Serial.flush();
}
//extern void LogError(char* msg, char* file, unsigned int line)
#define LOGERROR(msg) LogError(msg, __FILE__, __LINE__)
#define ASSERT(b) if(!(b)) LOGERROR("Assert failed")
#else
#define LOGERROR(s)
#define ASSERT(b)
#endif
#include "BH1750LightSensor.h"
BH1750LightSensor bh1750;
void setup()
{
Serial.begin(115200);
delay(1000);
Wire.begin();
Wire.setClock(400000);
Wire.setTimeout(100);
Serial.println("\n\n\rSTM32 Started...\n\n\r");
if (!bh1750.begin(&Wire, 0x23)) {
Serial.println("BH1750 not connected");
Serial.flush();
while (1);
}
#if 0
// make a list of actual measurement times vs. calculated times
// for each MTreg value 31..254
for (uint i = 31; i < 255; ++i) {
bh1750.setMeasurementTime(i);
ulong msHMODE, msLMODE;
bh1750.getConversionTimes(&msHMODE, &msLMODE);
ulong hmode = (120 * i) / 69;
ulong lmode = (16 * i) / 69;
char buf[200];
sprintf(buf, "%u\t%lu %lu\t%lu %lu", i, msHMODE, msLMODE, hmode, lmode);
Serial.println(buf);
}
Serial.flush();
#endif
// start first measurement
bh1750.setMeasurementTime(31);
bh1750.startMeasurement(bh1750.ONE_TIME, bh1750.HMODE);
}
void loop()
{
// if measurement is ready, read it and start the next measurement
if (bh1750.isReady()) {
uint lux;
bh1750.readLux(&lux);
Serial.println(lux);
bh1750.startMeasurement(bh1750.ONE_TIME, bh1750.HMODE);
}
}
LTR-507ALS (LiteOn) Optical Sensor
The LTR-507 has a couple of problems (see below) and LiteOn is not advertising it anymore. Maybe that's why these modules are cheap.The more recent LiteOn chips are better, like the improved LTR-329 which will soon be reviewed below.
This 3.3V chip has two functions, an Ambient Light Sensor (ALS) and a Proximity Sensor (PS). It computes the (hopefully accurate) lux value internally, so you don't need any dubious code to do the job. It also has a fully programmable interrupt pin (INT), which can signal the MCU when the light level is outside, or inside, a programmed range. It has two sensors, one for visible and infrared light (channel 1, CH1), and another for only (mostly) infrared light (channel 2, CH2). The lux value (the light level visible to the human eye) is calculated from channel 1 by removing the infrared component of channel 2.
The chip contains 33 registers, making it rather complicated to program. For full details you must read the data sheet (several times), but reading my code and the copious comments will help a lot. This chip seems to have been discontinued by LiteOn (it's not on their website anymore), and the more recent chips (LTR-3xx etc) have fewer registers and seem to be easier to use. The more recent chips do not seem to be available on easy-to-use modules.
The ALS and PS features can be enabled (activated) or disabled (disabled = standby, the power-on default, which saves power if its battery operated). The ALS feature has a programmable gain, bit resolution and sampling rate, see configureALS(). intuitively, the PS feature is configured with configurePS(). The interrupt behaviour and threshold light levels can also be programmed. Always configure the interrupts BEFORE activating ALS or PS. See the comments in the code for details.
There are four gain settings (1x = 1 lux/count; 2x = 0.5 lux/count; 100x = 0.01 lux/count; 200x = 0.005 lux/count), which can be selected during operation to adjust the the lux value range according to the light level, and prevent saturation.
Saturation
Digital saturation occurs when the light intensity is too high for the internal calculation. Analog saturation occurs when the level is too high for analog-to-digital converter. With a high gain setting this can happen at quite low light levels.
With 1x gain, the lux reading goes up to the maximum 65535 and stays there. With 2x gain, the maximum is 32767 lux, with 100x gain it is 655, and 200x is 327. If the level goes too much above the max. level, it drops to zero, which is not good.
To complicate things, each of the two sensors has a different saturation level. The full spectrum sensor is more susceptible to visible light and the IR sensor saturates when there's high infrared light. The LTR-507 also has trouble with lux calculation if the IR component of the light is too high, see next section on high levels of infrared.
I think there's a problem with the chip's handling of saturation. If the level goes up to the maximum value according to the gain, it stays at that value until overflow occurs, then it sets the lux value to 0. As the light level increases, the lux reading begins to drop for a while before saturation is recognised and the reading is set to zero. This could be due to arithmetic overflow in the on-chip calculations. I think this is why they added the ALS_IRF_CUT_OFF register, see next section.
If the lux value is 0, how do we know it is because of saturation or because there is no light and 0 lux is the correct value? If at the maximum level, how do we know if the internal calculation has overflowed? Unfortunately, the LTR-507 does not have an "overflow" or "saturation" bit for the lux value (it's only for the PS value). But the muman library has added features which handle both these situations...
If the raw lux reading is zero, it checks the channel 0 analog reading. If that is also zero then it assumes no light and returns 0 lux. But if the channel 0 reading is not zero, it means that overflow has occurred and it returns an impossible lux reading of -1.
If the raw lux value is the maximum value of 65535, the code checks the channel 0 value for overflow. I'm not sure exactly what value to check for, and it's affected by the gain setting. But I found that checking the most significant 4 bits (bits 19..16) for the value 1111 (0x0f) works well for all gain settings. If the MS 4 bits are all 1s, then it returns -1.
Both these checks are done in the readRawLux() method.
High levels of infrared light produce invalid lux readings
The LTR-507 sensor does not handle very high levels of infrared light well. The lux reading actually decreases for a while if the IR component becomes too high. They added a feature to handle this. You can configure a ratio between channel 1 and 2 with configureALSIRFCutoff(), which sets a maximum level for the infrared component. The default value for the ALS_IRF_CUT_OFF register is 0xD0 (208). If the IR levels are high and the ALS_IRF_CUT_OFF value is exceeded, the reading is set to 0 lux, and both analog channels are set to 0 too.
This feature is annoying because it sets the lux reading to 0 even for quite low light levels from an incandescent spot lamp (100W), and it's impossible to tell the difference between darkness and IR saturation. There is no way to turn off this feature.
I think the description in the data sheet is incorrect...
It says, "if ADCIR / ADCCLEAR > ALS_IRF_CUT_OFF, then ALS_DATA = 0". I think this should be, "if (256 * ADCIR) / ADCCLEAR > ALS_IRF_CUT_OFF, then ALS_DATA = 0".
In my tests "* 256" worked very well. You can test this yourself using an incandescent light bulb, these emit a lot of infrared. (But avoid touching the live wire, see Disclaimer.)
The ALS_IRF_CUT_OFF register does not seem to exist in later sensors from LiteOn.
Automatic gain selection
The code contains a unique function called autoSelectGain(long lux) which automatically sets the optimal gain (x1 .. x200) for the lux value returned by readLux(). It returns true if the gain was changed. If saturation or overflow has occurred (lux = -1) it will set the range to 0 (0..65535 lux, the default), so the next read will (hopefully) return a valid value. See the example sketch.
The code could be modified to switch between only two gain settings instead of four, e.g. 1x for high lux values (0..65535) and 200x for low lux values (0..326). This may make more sense.
I2C slave address
The address is selected by connecting the SEL pin to GND, VCC or leaving it floating. See JP3. Note that the Address Selection section on the SolderedElectronics website is wrong!
SEL
I2C Address
GND
0x3A (default for my SolderedElectronics board)
VCC (3.3V)
0x3B
floating
0x23
Proximity sensor's external IR LED
The Proximity Sensor (PS) requires an external infrared LED to be connected. It flashes the IR LED at a programmed rate. You can program the pulse frequency, peak current and pulse count with configurePS(). The reflected IR light determines the distance of an object, depending on its IR reflectivity. The distance is returned as an arbitrary value by readProximity(). This value is not in mm or cm, it's just the ADC value, so it must be calibrated/converted if you need an actual distance.
Connect the IR LED between the VLED output and VCC (+3.3V). The LED is turned on when the VLED output is pulled to GND by the chip. The LED current is limited by peakCurrent, so you don't need a series resistor.
The readProximity() method returns the proximity detection distance as the raw ADC CH2 value (it's not cm or mm). The value increases as the distance decreases. The detection range is about 5..20cm, depending on reflectivity. overflow is set if the reflector is too close (<5cm) and the ADC value has overflowed (>2047).
Here's the overview of the module on the SolderedElectronics website. On the left of the window you will see a table-of-contents where you can view the Overview, Hardware Details, How It Works, and their own Arduino Library (but my library is better ;-) https://soldered.com/documentation/ltr-507/overview/
MattLabs code
The C++ code was written and tested on a 32-bit STM32 Nucleo board, but it should run on most Arduino-style boards with minimal changes. My version provides more features than the official library, and I think it's easier to understand thanks to the copious comments. The code uses uint and ulong, the standard abbreviations for unsigned int and unsigned long. On a 32-bit device these are both 32-bit values. On a 16-bit device, uint is 16 bits and ulong is 32 bits, but everything should work for both.
// for uint and ulong
typedef unsigned int uint;
typedef unsigned long ulong;
This code detects saturation when the lux value overflows the gain setting, or if there's too much infrared light. If the lux register value is 0 (ALS_DATA = 0) and the ADC register CH1 is not zero, the code returns a negative lux value (-1) so you can tell the difference between an invalid reading and total darkness. The other libraries do not seem to detect this. This also returns -1 if the ALS_IRF_CUT_OFF level is exceeded.
#pragma once
// LiteOn LTR-507ALS Optical Sensor with I2C interface
// Copyright (C) muman.ch, 2025.09.05
// email: info@muman.ch
/*
See the muman blog for details
https://muman.ch/muman/index.htm?muman-light-sensors.htm
LTR-507 Data Sheet
https://optoelectronics.liteon.com/upload/download/DS86-2013-0014/LTR-507ALS-01_FINAL%20DS.pdf
The code was tested with the SolderedElectronics LTR507 board
https://soldered.com/documentation/ltr-507/overview/
*/
#include <Wire.h>
/* for example...
#ifdef DEBUG
#define LOGERROR(s) Serial.println(s); Serial.flush()
#define ASSERT(b) if (!(b)) LOGERROR("ASSERT failed")
#else
#define LOGERROR(s)
#define ASSERT(b)
#endif
*/
class LTR507ALSOpticalSensor
{
protected:
TwoWire* wire;
int i2cAdds;
public:
uint alsGain, alsResolution;
// 8-bit registers, see data sheet "6. Register Set"
enum REG
{
ALS_CONTR = 0x80, // ALS operation mode control SW reset
PS_CONTR = 0x81, // PS operation mode control
PS_LED = 0x82, // PS LED setting
PS_N_PULSES = 0x83, // PS number of pulses
PS_MEAS_RATE = 0x84, // PS measurement rate in active mode
ALS_MEAS_RATE = 0x85, // ALS measurement rate in active mode
PART_ID = 0x86, // Part Number ID and Revision ID
MANUFAC_ID = 0x87, // Manufacturer ID
ALS_DATA_0 = 0x88, // Direct ALS measurement, lower byte
ALS_DATA_1 = 0x89, // Direct ALS measurement, upper byte
ALS_PS_STATUS = 0x8A, // ALS and PS new data status
PS_DATA_0 = 0x8B, // PS measurement data, lower byte
PS_DATA_1 = 0x8C, // PS measurement data, upper byte
ALS_DATA_CH1_0 = 0x8D, // ALS measurement CH1 data, lower byte
ALS_DATA_CH1_1 = 0x8E, // ALS measurement CH1 data, mid byte
ALS_DATA_CH1_2 = 0x8F, // ALS measurement CH1 data, upper byte
ALS_DATA_CH2_0 = 0x90, // ALS measurement CH2 data, lower byte
ALS_DATA_CH2_1 = 0x91, // ALS measurement CH2 data, mid byte
ALS_DATA_CH2_2 = 0x92, // ALS measurement CH2 data, upper byte
ALS_COEFF1_DATA_0 = 0x93, // Coefficient for Clear diode, lower byte
ALS_COEFF1_DATA_1 = 0x94, // Coefficient for Clear diode, upper byte
ALS_COEFF2_DATA_0 = 0x95, // Coefficient for IR diode, lower byte
ALS_COEFF2_DATA_1 = 0x96, // Coefficient for IR diode, upper byte
ALS_IRF_CUT_OFF = 0x97, // ALS cut-off limit of IR factor
INTERRUPT = 0x98, // Interrupt settings
PS_THRES_UP_0 = 0x99, // PS interrupt upper threshold, lower byte
PS_THRES_UP_1 = 0x9A, // PS interrupt upper threshold, upper byte
PS_THRES_LOW_0 = 0x9B, // PS interrupt lower threshold, lower byte
PS_THRES_LOW_1 = 0x9C, // PS interrupt lower threshold, upper byte
ALS_THRES_UP_0 = 0x9E, // ALS interrupt upper threshold, lower byte
ALS_THRES_UP_1 = 0x9F, // ALS interrupt upper threshold, upper byte
ALS_THRES_LOW_0 = 0xA0, // ALS interrupt lower threshold, lower byte
ALS_THRES_LOW_1 = 0xA1, // ALS interrupt lower threshold, upper byte
INTERRUPT_PERSIST = 0xA4 // ALS / PS Interrupt persist setting
};
public:
bool begin(TwoWire* wire, int i2cAddress);
bool softwareReset();
bool readIDs(uint* partNumberID, uint* revisionID, uint* manufacturerID);
bool setMode(bool alsActive, bool psActive);
bool readLux(long* lux, bool* newData);
bool readLux(float* lux, bool* newData);
bool readRawLux(long* lux, bool* newData);
bool readProximity(uint* proximity, bool* overflow);
bool readStatus(uint* interruptSource, bool* alsInterruptStatus,
bool* alsDataStatus, bool* psInterruptStatus, bool* psDataStatus);
bool readRawAdcValues(ulong* adcCh1, ulong* adcCh2);
bool autoSelectGain(long lux);
bool configureALS(uint gain, uint resolution, uint rate);
bool configurePS(uint pulseFreq, uint peakCurrent, uint pulseCount, uint rate);
bool configureInterrupt(bool outputMode, bool polarity, uint interruptMode);
bool configureALSCoeff(uint coeffCh1, uint coeffCh2);
bool readALSCoeff(uint* coeffCh1, uint* coeffCh2);
bool configureALSInterruptThreshold(uint upper, uint lower);
bool readALSInterruptThreshold(uint* upper, uint* lower);
bool configurePSInterruptThreshold(uint upper, uint lower);
bool readPSInterruptThreshold(uint* upper, uint* lower);
bool configureInterruptPersist(uint alsPersist, uint psPersist);
bool readInterruptPersist(uint* alsPersist, uint* psPersist);
bool configureALSIRFCutoff(uint cutoff);
bool readALSIRFCutoff(uint* cutoff);
protected:
bool writeRegisters(REG reg, const byte* values, uint length);
bool readRegisters(REG reg, byte* values, uint length);
bool readRegister(REG reg, byte* value);
bool writeRegister(REG reg, byte value);
};
// Initialize Wire before calling this
// Wire.begin();
// Wire.setClock(1000000);
// Wire.setTimeout(100);
// ltr.begin(&Wire, 0x3a);
bool LTR507ALSOpticalSensor::begin(TwoWire* twoWire, int i2cAddress)
{
wire = twoWire;
i2cAdds = i2cAddress;
return softwareReset();
}
// Set all registers to default values
// ALS and PS set to standby
bool LTR507ALSOpticalSensor::softwareReset()
{
alsGain = 0; // 0 = x1, 0..65355 lux (default)
alsResolution = 4; // 16-bit resolution (default)
bool ok = writeRegister(ALS_CONTR, 0b00000100);
delay(100); // startup time, assume 100ms
return ok;
}
// Read the hard-wired chip IDs
bool LTR507ALSOpticalSensor::readIDs(uint* partNumberID, uint* revisionID, uint* manufacturerID)
{
byte data[2];
if (!readRegisters(PART_ID, data, 2))
return false;
*partNumberID = data[0] >> 4;
*revisionID = data[0] & 0x0f;
*manufacturerID = data[1];
return true;
}
// Sets active or standby mode for ALS and PS
// NOTE: Configure interrupts BEFORE setting Active mode
// alsActive : true = ambient light sensor ALS active; false = ALS standby
// psActive : true = position sensor PS active; false = PS standby
bool LTR507ALSOpticalSensor::setMode(bool alsActive, bool psActive)
{
byte contr[2];
if (!readRegisters(ALS_CONTR, contr, 2))
return false;
if (alsActive)
contr[0] |= 0b00000010;
else
contr[0] &= ~0b00000010;
if (psActive)
contr[1] |= 0b00000010;
else
contr[1] &= ~0b00000010;
return writeRegisters(ALS_CONTR, contr, 2);
}
// Reads the ambient light reading as a long integer
// 0..65535|32767|655|327 lux according to the gain
// the lux value is adjusted for the configured 'alsGain'
// 'newData' is true if this is a new value, false if it's not been updated
// a lux value of -1 (0xFFFFFFFF) means overflow or saturation
bool LTR507ALSOpticalSensor::readLux(long* lux, bool* newData)
{
long rawLux;
if (!readRawLux(&rawLux, newData))
return false;
if (rawLux <= 0) {
*lux = rawLux;
return true;
}
// lux value depends on 'gain'
long lux1 = rawLux;
switch (alsGain) {
case 0: // x1, 1 lux/count
//lux1 = rawLux;
break;
case 1: // x2, 0.5 lux/count
lux1 = rawLux >> 1;
break;
case 2: // x100, 0.01 lux/count
lux1 = (rawLux + 49) / 100;
break;
case 3: // x200, 0.005 lux/count
lux1 = (rawLux + 99) / 200;
break;
}
*lux = lux1;
return true;
}
// Reads the ambient light reading as a floating point value
// use this for very low lux levels
// 0..65535|32767.5|655.35|327.675 lux according to the gain
// the lux value is adjusted for the configured 'alsGain'
// 'newData' is true if this is a new value, false if it's not been updated
// a lux value of -1 means overflow or saturation
bool LTR507ALSOpticalSensor::readLux(float* lux, bool* newData)
{
long rawLux;
if (!readRawLux(&rawLux, newData))
return false;
float flux = (float)rawLux;
// lux value depends on 'gain'
if (rawLux > 0) {
switch (alsGain) {
case 0: // x1, 1 lux/count
break;
case 1: // x2, 0.5 lux/count
flux /= 2.0f;
break;
case 2: // x100, 0.01 lux/count
flux /= 100.0f;
break;
case 3: // x200, 0.005 lux/count
flux /= 200.0f;
break;
}
}
*lux = flux;
return true;
}
// Reads the lux value, unadjusted for the gain setting
// this returns 0..65535 regardless of the gain setting
// the actual lux value must be adjusted for the gain
// if saturation or overflow occurs it returns lux = -1
bool LTR507ALSOpticalSensor::readRawLux(long* lux, bool* newData)
{
byte data[3];
if (!readRegisters(ALS_DATA_0, data, 3))
return false;
long rawLux = (data[1] << 8) + data[0];
*newData = (data[2] & 0x04) != 0;
if (*newData) {
if (rawLux == 0 || rawLux == 65535) {
ulong adcCh1, adcCh2;
if (!readRawAdcValues(&adcCh1, &adcCh2))
return false;
// saturation or overflow
if ((adcCh1 & 0x000f0000) == 0x000f0000)
rawLux = -1;
}
}
*lux = rawLux;
return true;
}
// Automatically sets the optimum alsGain according to the lux value
// this affects the next call to readLux()
// lex = the last lux value from readLux(), even if -1
// if lux == -1 (saturation or overflow) then alsGain is set to 0 (x1)
// of lux == 0, it means absolute darkness or ALS_IRF_CUT_OFF (cannot tell)
// returns true if alsGain was changed, false if not
bool LTR507ALSOpticalSensor::autoSelectGain(long lux)
{
uint newGain = 0;
if (lux >= 32767 || lux == -1 || lux == 0)
newGain = 0;
else if (lux >= 655)
newGain = 1;
else if (lux >= 327)
newGain = 2;
else
newGain = 3;
if (newGain == alsGain)
return false;
alsGain = newGain;
//Serial.printf("\n\ralsGain changed to %u\n\r", newGain);
// note: ALS Mode is assumed to be Active!
return writeRegister(ALS_CONTR, 0b00000010 | (alsGain << 3));
}
// Returns proximity detection distance value
// this requires external IR LED connected between VLED (K) and VCC (A)
// 'proximity' is the ADC value (not cm or mm), it increases as the distance decreases
// the range ~5..20cm depending on reflectivity
// 'overflow' is set when the distance is too close, value = 2047
bool LTR507ALSOpticalSensor::readProximity(uint* proximity, bool* overflow)
{
byte data[2];
if (!readRegisters(PS_DATA_0, data, 2))
return false;
*overflow = (bool)(data[1] & 0b00010000);
*proximity = ((data[1] & 7) << 8) + data[0]; // 11-bit
return true;
}
// Return interrupt and data status from ALS_PS_STATUS register
// interruptSource : 0 = no interrupt; 1 = PS interrupt; 2 = ALS interrupt
bool LTR507ALSOpticalSensor::readStatus(uint* interruptSource, bool* alsInterruptStatus,
bool* alsDataStatus, bool* psInterruptStatus, bool* psDataStatus)
{
byte b;
if (!readRegister(ALS_PS_STATUS, &b))
return false;
*interruptSource = (b >> 4) & 0x03;
*alsInterruptStatus = (bool)(b & 0b00001000);
*alsDataStatus = (bool)(b & 0b00000100);
*psInterruptStatus = (bool)(b & 0b00000010);
*psDataStatus = (bool)(b & 0b00000001);
return true;
}
// Returns the raw ADC values for the ALS sensor 'clear diode' (CH1)
// and the PS sensor 'IR diode' (CH2)
// values are 4..20 significant bits according to 'resolution'
// >>> the MS bit is always bit 19 <<<
bool LTR507ALSOpticalSensor::readRawAdcValues(ulong* adcCh1, ulong* adcCh2)
{
byte data[6];
if (!readRegisters(ALS_DATA_CH1_0, data, 6))
return false;
*adcCh1 = (data[2] << 12) + (data[1] << 4) + (data[0] >> 4);
*adcCh2 = (data[5] << 12) + (data[4] << 4) + (data[3] >> 4);
return true;
}
// Configure the Ambient Light Sensor (ALS)
// gain : 0 = 1x, 1..65535 lux (default) (1 lux/count)
// 1 = 2x, 0.5..32767 lux (0.5 lux/count)
// 2 = 100x, 0.02..655 lux (0.01 lux/count)
// 3 = 200x, 0.01..327 lux (0.005 lux/count)
// resolution : 0 = 20 bits; 1 = 19; 2 = 18; 3 = 17; 4 = 16 (default); 5 = 12;
// 6 = 8; 7 = 4 bits
// NOTE! 8 and 4-bit resolutions don't really make any sense???
// rate : 0 = 100ms; 1 = 200ms; 3 = 500ms (default); 3 = 1000ms; 4..7 = 2000ms
bool LTR507ALSOpticalSensor::configureALS(uint gain, uint resolution, uint rate)
{
ASSERT(gain < 4 && resolution < 8 && rate < 8);
// alsGain is used to get the actual LUX reading, see readLightIntensity()
alsGain = gain;
alsResolution = resolution;
byte b;
if (!readRegister(ALS_CONTR, &b))
return false;
// keep ALS mode bit 1
b = (b & 0b00000010) | (gain << 3);
if (!writeRegister(ALS_CONTR, b))
return false;
b = (resolution << 5) | rate;
return writeRegister(ALS_MEAS_RATE, b);
}
// Configure the Proximity Sensor (PS) and the external IR LED
// pulseFreq : 0 = 30kHz; 1 = 40kHz; 2 = 50kHz; 3 = 60kHz (default); 4 = 70kHz;
// 5 = 80kHz; 6 = 90kHz; 7 = 100kHz
// peakCurrent : 0 = 5mA; 1 = 10mA; 2 = 20mA; 3 = 50mA (default); 4..7 = 100mA
// pulseCount : number of pulses, 0..255, default = 127
// rate : 0 = 12.5ms; 1 = 50ms; 2 = 70ms; 3 = 100ms (default); 4 = 200ms;
// 5 = 500ms; 6 = 1000ms; 7 = 2000ms
bool LTR507ALSOpticalSensor::configurePS(uint pulseFreq, uint peakCurrent, uint pulseCount, uint rate)
{
ASSERT(pulseFreq < 8 && peakCurrent < 8 && pulseCount < 256 && rate < 8);
byte b = (pulseFreq << 5) | 0b00001000 | peakCurrent;
if (!writeRegister(PS_LED, b))
return false;
if (!writeRegister(PS_N_PULSES, (byte)pulseCount))
return false;
return writeRegister(PS_MEAS_RATE, (byte)rate);
}
// Interrupt Configuration
// NOTE: Configure interrupts BEFORE setting Active mode
// Connect INT output to an input which generates an interrupt, then use attachInterrupt()
// outputMode : 0 = latched until ALS_PS_STATUS is read; 0 = updated after every measurement (default)
// polarity : 0 = active low (default); 1 = active high
// interruptMode : 0 = inactive; 1 = PS interrupt only; 2 = ALS interrupt only; 3 = PS and ALS interrupts
bool LTR507ALSOpticalSensor::configureInterrupt(bool outputMode, bool polarity, uint interruptMode)
{
ASSERT((unsigned)interruptMode < 4);
byte b = interruptMode & 3;
if (outputMode)
b |= 0b00001000;
if (polarity)
b |= 0b00000100;
return writeRegister(INTERRUPT, b);
}
bool LTR507ALSOpticalSensor::configureALSInterruptThreshold(uint upper, uint lower)
{
ASSERT(upper <= 0xffff && lower <= 0xffff); // 16-bit
return writeRegisters(ALS_THRES_UP_0, (byte*)&upper, 2) &&
writeRegisters(ALS_THRES_LOW_0, (byte*)&lower, 2);
}
bool LTR507ALSOpticalSensor::readALSInterruptThreshold(uint* upper, uint* lower)
{
byte data[4];
if (!readRegisters(ALS_THRES_UP_0, data, 4))
return false;
*upper = (data[1] << 8) + data[0];
*lower = (data[3] << 8) + data[2];
return true;
}
bool LTR507ALSOpticalSensor::configurePSInterruptThreshold(uint upper, uint lower)
{
ASSERT(upper < 0x800 && lower < 0x800); // 11-bit
if (!writeRegisters(PS_THRES_UP_0, (byte*)&upper, 2))
return false;
return writeRegisters(PS_THRES_LOW_0, (byte*)&lower, 2);
}
bool LTR507ALSOpticalSensor::readPSInterruptThreshold(uint* upper, uint* lower)
{
byte data[4];
if (!readRegisters(PS_THRES_UP_0, data, 4))
return false;
*upper = ((data[1] & 7) << 8) + data[0];
*lower = ((data[3] & 7) << 8) + data[2];
return true;
}
bool LTR507ALSOpticalSensor::configureInterruptPersist(uint alsPersist, uint psPersist)
{
ASSERT(alsPersist < 16 && psPersist < 16); // 4-bit
byte b = (psPersist << 4) + alsPersist;
return writeRegister(INTERRUPT_PERSIST, b);
}
bool LTR507ALSOpticalSensor::readInterruptPersist(uint* alsPersist, uint* psPersist)
{
byte b;
if (!readRegister(INTERRUPT_PERSIST, &b))
return false;
*alsPersist = b & 0x0f;
*psPersist = b >> 4;
return true;
}
// Coefficients for calculating luminance in LUX
// >>> it's probably best not to change these <<<
// defaults : coeffCh1 = 0x0380, coeffCh2 = 0xfbc8
bool LTR507ALSOpticalSensor::configureALSCoeff(uint coeffCh1, uint coeffCh2)
{
ASSERT(coeffCh1 <= 0xffff && coeffCh2 <= 0xffff); // 16-bit
return writeRegisters(ALS_COEFF1_DATA_0, (byte*)(&coeffCh1), 2) &&
writeRegisters(ALS_COEFF2_DATA_0, (byte*)(&coeffCh2), 2);
}
bool LTR507ALSOpticalSensor::readALSCoeff(uint* coeffCh1, uint* coeffCh2)
{
byte data[4];
if (!readRegisters(ALS_COEFF1_DATA_0, data, 4))
return false;
*coeffCh1 = (data[1] << 8) + data[0];
*coeffCh2 = (data[3] << 8) + data[2];
return true;
}
// ALS cutoff limit of IR factor, default = 0xD0 (208)
// too much infrared light?
// when the IR factor exceeds the cut-off limit, the output value is set to '0'.
// if ((ADCIR * 256) / ADCCLEAR) > ALS_IRF_CUT_OFF, then ALS_DATA := 0
bool LTR507ALSOpticalSensor::configureALSIRFCutoff(uint cutoff)
{
ASSERT(cutoff <= 0xff);
return writeRegister(ALS_IRF_CUT_OFF, (byte)cutoff);
}
// if (ADCIR / ADCCLEAR) > ALS_IRF_CUT_OFF, then ALS_DATA := 0
bool LTR507ALSOpticalSensor::readALSIRFCutoff(uint* cutoff)
{
byte b;
if (!readRegister(ALS_IRF_CUT_OFF, &b))
return false;
*cutoff = b;
return true;
}
// Read/write multiple 8-bit registers
bool LTR507ALSOpticalSensor::readRegisters(REG reg, byte* values, uint length)
{
wire->beginTransmission(i2cAdds);
if (wire->write((byte)reg) != 1) {
LOGERROR("write failed");
return false;
}
if (wire->endTransmission() != 0) {
LOGERROR("endtx failed");
return false;
}
if (wire->requestFrom(i2cAdds, length) != length) {
LOGERROR("requestFrom failed");
return false;
}
if (wire->readBytes(values, length) != length) {
LOGERROR("readBytes failed");
return false;
}
return true;
}
bool LTR507ALSOpticalSensor::writeRegisters(REG reg, const byte* values, uint length)
{
wire->beginTransmission(i2cAdds);
if (wire->write((byte)reg) != 1) {
LOGERROR("write failed");
return false;
}
if (wire->write(values, length) != length) {
LOGERROR("write failed");
return false;
}
if (wire->endTransmission() != 0) {
LOGERROR("endtx failed");
return false;
}
return true;
}
// Read/write a single 8-bit register
bool LTR507ALSOpticalSensor::readRegister(REG reg, byte* value)
{
return readRegisters(reg, value, 1);
}
bool LTR507ALSOpticalSensor::writeRegister(REG reg, byte value)
{
return writeRegisters(reg, &value, 1);
}
Here's a full sketch, which outputs various values to the Serial port and indicates some of the "problems". Note that it uses sprintf() with float support, if you don't have this you can use these floating point routines.
Like most others, this chip contains two sensors. A broad spectrum photodiode (analog channel CH1) which registers both visible and infrared light, and a photodiode that registers mostly infrared light (analog channel CH2).
It runs from 3.3V. The maximum reading is 88000 lux, and they say the minimum reading is 377 microlux (0.000377 lux). It can generate a fully programmable interrupt from light intensity, duration and persistence.
I2C slave address
The I2C address is fixed at 0x29.
Calculating the LUX value
To get the human visible light level in Lux, we must process the raw CH0 and CH1 values. Unlike the LiteOn versions, this is not done by the chip itself, so you have to write code to do this. But this is problematic...
The data sheet states, "The digital output can be input to a microprocessor where illuminance (ambient light level) in lux is derived using an empirical formula to approximate the human eye response." But the data sheet does not provide this empirical formula. Each library I found uses a different formula and they all produce different results. My code contains the equations from all the libraries I found, so you can choose which algorithm you want to use, or you can develop your own. I found that the Waveshare library had the best results which were the closest to the readings from my cheap Voltcraft LX-10 Lux meter (*).
The Waveshare library lux calculation is the same as described in the Taos application note, apart from the CPL (counts-per-lux) divisor.
Below is the Mattlabs source code, with comments and links to each library.
The "counts-per-lux" value (CPL) is calculated just once when the chip is configured, from the gain and the integration time, see computeCpl(uint gain, uint time).
The floating point lux value is calculated from the raw readings of the two channels by float computeLux(unit ch0, uint ch1). It's not an integer value because the equations require floating point, but they could be converted to integers by using a multiplier.
// Compute the approximate lux value from the raw ALS data of both channels
// returns -1 on overflow
float TSL2591LightSensor::computeLux(uint ch0, uint ch1)
{
// overflow, return -1 (< 0)
if (ch0 == 0xffff || ch1 == 0xffff) {
return -1.0f;
// OR you could return "not-a-number", a float NAN
// you must use isnan(f) to check for this return value
//return NAN;
}
// prevent divide-by-zero, assume 0 lux
// (ch0 is visible light)
if (ch0 == 0 || fcpl == 0.0f) {
return 0.0f;
}
// convert raw readings to float, just once
float fch0 = (float)ch0; // ambient light + infra-red
float fch1 = (float)ch1; // mostly infra-red
// Here are three different LUX calculations from the TSL2591 libraries
// In comparison with a cheap Voltcraft LX-10 lux meter, the Waveshare
// formula is the closest match
// This is the original calculation from the first Adafruit library.
// NOTE: The latest Adafruit library uses a different formula (see below).
// It calculates two linear slopes which cross over each other.
// It does not know which slope to use, so it takes the higher reading.
// https://github.com/adafruit/Adafruit_TSL2591_Library/blob/master/Adafruit_TSL2591.cpp#L278
// See "Figure 6 – Excel Lux Equation using Multiple Points" in
// https://look.ams-osram.com/m/2fbeab1da7b52219/original/AmbientLightSensors-AN000173.pdf
// luxa = flourescent/incandescent/sunlight light
// luxb = dim incandescent light or dim sunlight
float luxa = (fch0 - (fch1 * 1.64f)) / fcpl;
float luxb = ((fch0 * 0.59f) - (fch1 * 0.86f)) / fcpl;
// choose the highest reading
float lux1 = luxa > luxb ? luxa : luxb;
// The new formula used by the Adafruit library - I think it's wrong
// see https://github.com/adafruit/Adafruit_TSL2591_Library/issues/14
// https://github.com/adafruit/Adafruit_TSL2591_Library/blob/master/Adafruit_TSL2591.cpp#L220
// DIVIDE BY ZERO IF ch0 = 0 (I fixed this by checking ch0 above)
float lux2 = ((fch0 - fch1) * (1.0f - (fch1 / fch0))) / fcpl;
// The 'Waveshare' formula
// https://github.com/waveshare/TSL2591X-Light-Sensor/blob/master/Arduino/TSL2591_Light_Sensor/TSL2591.cpp#L180
// It is very similar to the original Adafruit library version above.
// It's the example formula from "Using the Lux equation":
// https://look.ams-osram.com/m/2868a9784356cfc6/original/AmbientLightSensors-AN000170.pdf
// cpl = (atime * again) / (GA * DF) -> (atime * again) / 762.0
// GA = glass attenuation factor
// DF = device factor (experimentally derived, varies by device type), uses 53
// luxa = (ch0 - (2 * ch1)) / cpl;
// luxb = ((0.6 * ch0) - ch1) / cpl;
// THE WAVESHARE LIBRARY CODE SILENTLY SETS luxb TO 0! THIS IS VERY NAUGHTY!
float lux3a = (fch0 - (2.0f * fch1)) / fcpl;
float lux3b = ((0.6f * fch0) - fch1) / fcpl;
float lux3 = lux3a > lux3b ? lux3a : lux3b; // the Waveshare library code sets luxb to 0!
#if 1
// print all results
char buf[100];
sprintf(buf, "TSL2591 : lux1=%.03f lux2=%.03f lux3=%.03f", lux1, lux2, lux3);
Serial.println(buf);
#endif
// on comparison with a cheap Voltcraft LX-10 lux meter, lux3 is the closest match
float lux = lux3;
// lux can go -ve with very low light levels
if (lux < 0.0f)
lux = 0.0f;
return lux;
}
// Compute counts-per-lux for lux calculation
// this is stored in 'fcpl' to save re-calculating it every time
float TSL2591LightSensor::computeCpl(uint again, uint atime)
{
const float fgains[4] = { 1.0f, 25.0f, 428.0f, 9876.0f };
float fgain = fgains[again];
float ftime = (float)((atime + 1) * 100); // integration time in ms
// cpl = (atime_ms * again) / (GA * DF) //GA=7.698 DF=53??
// GA is Glass Attenuation
// DF is Device Factor (53?)
// Adafruit library uses 408
// Waveshare library uses 762 <- we use this one
return (fgain * ftime) / 762.0f;
}
Here's the algorithm from the TSL2571 data sheet (the TSL2591 data sheet does not contain the algorithm). This is the formula used by the Waveshare library.
The counts-per-lux value (CPL) needs to be calculated only once, when ATIME or AGAIN is changed. The first line of the equation (Lux1) covers fluorescent and incandescent light. The second line (Lux2) covers dimmed incandescent light. The final lux value is the maximum of Lux1, Lux2 or 0. GA is the "glass attenuation", 53 is the "device factor" (DF). The Waveshare formula uses 762 for (GA x DF).
Here's a plot for three different light sources, for the TSL2771 chip, which is similar. It shows two linear plots for different light sources, which cross each other. That's probably why the library simply returns the largest value, because it does not know which plot to use.
The C++ code was written and tested on a 32-bit STM32 Nucleo board, but it should run on most Arduino-style boards with minimal changes. My version provides more features than the official library, and I think it's easier to understand thanks to the copious comments. The code uses uint and ulong, the standard abbreviations for unsigned int and unsigned long. On a 32-bit device these are both 32-bit values. On a 16-bit device, uint is 16 bits and ulong is 32 bits, but everything should work for both.
The comments in the code should make it easy to understand, and there are nice descriptions of the registers in the data sheet.
#pragma once
// TSL2591 Dynamic Range Light Sensor with I2C interface
// Copyright (C) muman.ch, 2025.08.21
// email: info@muman.ch
/*
See the muman blog for details
https://muman.ch/muman/index.htm?muman-light-sensors.htm#muman-tsl2591
Data Sheet
https://look.ams-osram.com/m/c901de8e97608f8/original/TSL2591-DS000338.pdf
Adafruit Module
https://learn.adafruit.com/adafruit-tsl2591
*/
#include <Wire.h>
class TSL2591LightSensor
{
protected:
TwoWire* wire;
int i2cAdds;
// counts-per-lux, for lux calculation, see computeCpl()
float fcpl = 0.0f;
public:
// 8-bit registers
enum REG
{
// read/write registers
ENABLE = 0x00, // Enables states and interrupts
CONFIG = 0x01, // ALS gain and integration time configuration
AILTL = 0x04, // ALS interrupt low threshold low byte
AILTH = 0x05, // ALS interrupt low threshold high byte
AIHTL = 0x06, // ALS interrupt high threshold low byte
AIHTH = 0x07, // ALS interrupt high threshold high byte
NPAILTL = 0x08, // No Persist ALS interrupt low threshold low byte
NPAILTH = 0x09, // No Persist ALS interrupt low threshold high byte
NPAIHTL = 0x0A, // No Persist ALS interrupt high threshold low byte
NPAIHTH = 0x0B, // No Persist ALS interrupt high threshold high byte
PERSIST = 0x0C, // Interrupt persistence filter
// read-only registers
PID = 0x11, // Package ID
ID = 0x12, // Device ID
STATUS = 0x13, // Device status
C0DATAL = 0x14, // CH0 ADC low data byte
C0DATAH = 0x15, // CH0 ADC high data byte
C1DATAL = 0x16, // CH1 ADC low data byte
C1DATAH = 0x17 // CH1 ADC high data byte
};
bool begin(TwoWire* twoWire, int i2cAddress);
bool readIDs(uint* deviceId, uint* packageId);
bool reset();
bool enable(bool alsEnable, bool powerOn);
bool configureALS(uint again, uint atime);
bool configureInterruptThreshold(uint lowThreshold, uint highThreshold);
bool configureNoPersistThreshold(uint lowThreshold, uint highThreshold);
bool configureInterruptPersist(uint persist);
bool enableInterrupt(bool alsInterruptEnable, bool noPersistInterruptEnable, bool sleepAfterInterrupt);
bool clearInterrupt(bool clearAlsInterrupt, bool clearNoPersistInterrupt);
bool readStatus(bool* alsInterrupt, bool* noPersistInterrupt, bool* alsValid);
bool readRawAdcValues(uint* ch0, uint* ch1);
bool readLux(float* lux);
float computeLux(uint ch0, uint ch1);
protected:
float computeCpl(uint again, uint atime);
bool writeRegisters(REG reg, const byte* values, uint length);
bool readRegisters(REG reg, byte* values, uint length);
bool readRegister(REG reg, byte* value);
bool writeRegister(REG reg, byte value);
bool writeSpecialFunction(byte sf);
};
// note: the I2C address is fixed at 0x29
bool TSL2591LightSensor::begin(TwoWire* twoWire, int i2cAddress)
{
wire = twoWire;
i2cAdds = i2cAddress;
// ensure default configuration
return reset();
}
bool TSL2591LightSensor::readIDs(uint* deviceId, uint* packageId)
{
byte data[2];
if (!readRegisters(PID, data, 2))
return false;
*deviceId = data[1];
*packageId = (data[0] >> 4) & 3;
return true;
}
bool TSL2591LightSensor::reset()
{
if (!writeRegister(CONFIG, 0x80))
return false;
delay(100); // how long to reset?
// compute counts-per-lux for lux calculation
fcpl = computeCpl(0, 0);
return true;
}
bool TSL2591LightSensor::enable(bool alsEnable, bool powerOn)
{
// preserve interrupt enable bits
byte b;
if (!readRegister(ENABLE, &b))
return false;
b &= 0b11010000;
if (alsEnable)
b |= 0b00000010;
if (powerOn)
b |= 0b00000001;
return writeRegister(ENABLE, b);
}
// Configure ALS gain and integration time
// gain : 0 = low gain (1x); 1 = medium gain (25x); 2 = high gain (428x); 3 = max. gain (9876x)
// time : 0 = 100ms max. count = 37888; [pdf says 36863?]
// 1 = 200ms max. count = 65535; 2 = 300ms; 3 = 400ms; 4 = 500ms; 5 = 600ms
bool TSL2591LightSensor::configureALS(uint again, uint atime)
{
if (again > 3 || atime > 5) {
ASSERT(false);
fcpl = 0.0f;
return false;
}
// pre-calculate counts-per-lux for the lux calculation
fcpl = computeCpl(again, atime);
byte b = (again << 4) | atime;
return writeRegister(CONFIG, b);
}
// Configure ALS interrupt threshold
bool TSL2591LightSensor::configureInterruptThreshold(uint lowThreshold, uint highThreshold)
{
byte data[4];
data[0] = (byte)lowThreshold;
data[1] = (byte)(lowThreshold >> 8);
data[2] = (byte)highThreshold;
data[3] = (byte)(highThreshold >> 8);
return writeRegisters(AILTL, data, 4);
}
// Configure No Persist ALS interrupt threshold
bool TSL2591LightSensor::configureNoPersistThreshold(uint lowThreshold, uint highThreshold)
{
byte data[4];
data[0] = (byte)lowThreshold;
data[1] = (byte)(lowThreshold >> 8);
data[2] = (byte)highThreshold;
data[3] = (byte)(highThreshold >> 8);
return writeRegisters(NPAILTL, data, 4);
}
// The Interrupt persistence filter sets the number of consecutive
// out-of-range ALS cycles necessary to generate an interrupt.
// Out-of-range is determined by comparing C0DATA (0x14 and 0x15)
// to the interrupt threshold registers (0x04 - 0x07).
//
// persist : 0 = interrupt on every ALS cycle
// 1 = interrupt when outside threshold
// 2..15 = consecutive values outside range:
// persist 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// count 2 3 5 10 15 20 25 30 35 40 45 50 55 60
bool TSL2591LightSensor::configureInterruptPersist(uint persist)
{
ASSERT(persist < 16);
return writeRegister(PERSIST, persist);
}
// Enable/disable interrupts
bool TSL2591LightSensor::enableInterrupt(bool alsInterruptEnable,
bool noPersistInterruptEnable, bool sleepAfterInterrupt)
{
// preserve AEN and PEN bits
byte b;
if (!readRegister(ENABLE, &b))
return false;
b &= 0b00000011;
if (alsInterruptEnable)
b |= 0b00010000;
if (noPersistInterruptEnable)
b |= 0b10000000;
if (sleepAfterInterrupt)
b |= 0b01000000;
return writeRegister(ENABLE, b);
}
// Acknowledge/clear an interrupt
bool TSL2591LightSensor::clearInterrupt(bool clearAlsInterrupt,
bool clearNoPersistInterrupt)
{
uint sf;
if (clearAlsInterrupt && clearNoPersistInterrupt)
sf = 0x07;
else if (clearAlsInterrupt)
sf = 0x06;
else if (clearNoPersistInterrupt)
sf = 0x0a;
else {
ASSERT(false);
return false;
}
return writeSpecialFunction(sf);
}
// Read interrupt and data status
bool TSL2591LightSensor::readStatus(bool* alsInterrupt,
bool* noPersistInterrupt, bool* alsValid)
{
byte b;
if (!readRegister(STATUS, &b))
return false;
*alsInterrupt = (bool)(b & 0b00010000);
*noPersistInterrupt = (bool)(b & 0b00100000);
*alsValid = (bool)(b & 0b00000001);
return true;
}
//TODO , bool* alsValid
// Read the raw ALS data values for both channels
bool TSL2591LightSensor::readRawAdcValues(uint* ch0, uint* ch1)
{
byte data[4];
if (!readRegisters(C0DATAL, data, 4))
return false;
*ch0 = (data[1] << 8) | data[0];
*ch1 = (data[3] << 8) | data[2];
return true;
}
// Return the reading in lux
bool TSL2591LightSensor::readLux(float* lux)
{
uint ch0, ch1;
if (!readRawAdcValues(&ch0, &ch1)) {
*lux = 0.0f;
return false;
}
*lux = computeLux(ch0, ch1);
return true;
}
// Compute the approximate lux value from the raw ALS data of both channels
// returns -1 on overflow
float TSL2591LightSensor::computeLux(uint ch0, uint ch1)
{
// overflow, return -1 (< 0)
if (ch0 == 0xffff || ch1 == 0xffff) {
return -1.0f;
// OR you could return "not-a-number", a float NAN
// you must use isnan(f) to check for this return value
//return NAN;
}
// prevent divide-by-zero, assume 0 lux
// (ch0 is visible light)
if (ch0 == 0 || fcpl == 0.0f) {
return 0.0f;
}
// convert raw readings to float, just once
float fch0 = (float)ch0; // ambient light + infra-red
float fch1 = (float)ch1; // mostly infra-red
// Here are three different LUX calculations from the TSL2591 libraries
// In comparison with a cheap Voltcraft LX-10 lux meter, the Waveshare
// formula is the closest match (lux3)
// This is the original calculation from the first Adafruit library.
// NOTE: The latest Adafruit library uses a different formula (see below).
// It calculates two linear slopes which cross over each other.
// It does not know which slope to use, so it takes the higher reading.
// https://github.com/adafruit/Adafruit_TSL2591_Library/blob/master/Adafruit_TSL2591.cpp#L278
// See "Figure 6 – Excel Lux Equation using Multiple Points" in
// https://look.ams-osram.com/m/2fbeab1da7b52219/original/AmbientLightSensors-AN000173.pdf
// luxa = flourescent/incandescent/sunlight light
// luxb = dim incandescent light or dim sunlight
float luxa = (fch0 - (fch1 * 1.64f)) / fcpl;
float luxb = ((fch0 * 0.59f) - (fch1 * 0.86f)) / fcpl;
// choose the highest reading
float lux1 = luxa > luxb ? luxa : luxb;
// The new formula used by the Adafruit library - I think it's wrong
// see https://github.com/adafruit/Adafruit_TSL2591_Library/issues/14
// https://github.com/adafruit/Adafruit_TSL2591_Library/blob/master/Adafruit_TSL2591.cpp#L220
// DIVIDE BY ZERO IF ch0 = 0 (I fixed this by checking ch0 above)
float lux2 = ((fch0 - fch1) * (1.0f - (fch1 / fch0))) / fcpl;
// The 'Waveshare' formula
// https://github.com/waveshare/TSL2591X-Light-Sensor/blob/master/Arduino/TSL2591_Light_Sensor/TSL2591.cpp#L180
// It is very similar to the original Adafruit library version above.
// It's the example formula from "Using the Lux equation":
// https://look.ams-osram.com/m/2868a9784356cfc6/original/AmbientLightSensors-AN000170.pdf
// cpl = (atime * again) / (GA * DF) -> (atime * again) / 762.0
// GA = glass attenuation factor
// DF = device factor (experimentally derived, varies by device type), uses 53
// luxa = (ch0 - (2 * ch1)) / cpl;
// luxb = ((0.6 * ch0) - ch1) / cpl;
// THE WAVESHARE LIBRARY CODE SILENTLY SETS luxb TO 0! THIS IS VERY NAUGHTY!
float lux3a = (fch0 - (2.0f * fch1)) / fcpl;
float lux3b = ((0.6f * fch0) - fch1) / fcpl;
float lux3 = lux3a > lux3b ? lux3a : lux3b; // the Waveshare library code sets luxb to 0!
#if 1
// print all results
char buf[100];
sprintf(buf, "TSL2591 : lux1=%.03f lux2=%.03f lux3=%.03f", lux1, lux2, lux3);
Serial.println(buf);
sprintf(buf, "ch0=%u ch1=%u\n\r", ch0, ch1);
Serial.println(buf);
#endif
// on comparison with a cheap Voltcraft LX-10 lux meter, lux3 is the closest match
float lux = lux3;
// lux can go -ve with very low light levels
if (lux < 0.0f)
lux = 0.0f;
// infrared factor (IRF)
// flourescent/LED = ~0.8; sunlight = ~0.5; incadescent = ~0.2
//float irf = 1.0f - (fch1 / fch0);
//Serial.printf("irf=%f\n\r", irf);
return lux;
}
// Compute counts-per-lux for lux calculation
// this is stored in 'fcpl' to save re-calculating it every time
float TSL2591LightSensor::computeCpl(uint again, uint atime)
{
const float fgains[4] = { 1.0f, 25.0f, 428.0f, 9876.0f };
float fgain = fgains[again];
float ftime = (float)((atime + 1) * 100); // integration time in ms
// cpl = (atime_ms * again) / (GA * DF)
// GA is Glass Attenuation
// DF is Device Factor (53?)
// Adafruit library uses 408
// Waveshare library uses 762 <- we use this one
return (fgain * ftime) / 762.0f;
}
// Read/write multiple 8-bit registers
bool TSL2591LightSensor::readRegisters(REG reg, byte* values, uint length)
{
ASSERT(reg < 0x18);
wire->beginTransmission(i2cAdds);
// set bits 7 and 5 (CMD=1 | TRANSACTION=01) to write COMMAND register
// bits 4..0 are the register address
if (wire->write((byte)(0b10100000 | reg)) != 1) {
LOGERROR("write failed");
return false;
}
if (wire->endTransmission() != 0) {
LOGERROR("endtx failed");
return false;
}
if (wire->requestFrom(i2cAdds, length) != length) {
LOGERROR("requestFrom failed");
return false;
}
if (wire->readBytes(values, length) != length) {
LOGERROR("readBytes failed");
return false;
}
return true;
}
bool TSL2591LightSensor::writeRegisters(REG reg, const byte* values, uint length)
{
ASSERT(reg < 0x18);
wire->beginTransmission(i2cAdds);
// set bits 7 and 5 (CMD=1, TRANSACTION=01) to write COMMAND register
// bits 4..0 are the register address
if (wire->write((byte)(0b10100000 | reg)) != 1) {
LOGERROR("write failed");
return false;
}
if (wire->write(values, length) != length) {
LOGERROR("write failed");
return false;
}
if (wire->endTransmission() != 0) {
LOGERROR("endtx failed");
return false;
}
return true;
}
// Read/write a single 8-bit register
bool TSL2591LightSensor::readRegister(REG reg, byte* value)
{
return readRegisters(reg, value, 1);
}
bool TSL2591LightSensor::writeRegister(REG reg, byte value)
{
return writeRegisters(reg, &value, 1);
}
// Special function
bool TSL2591LightSensor::writeSpecialFunction(byte sf)
{
ASSERT(sf == 0x04 || sf == 0x06 || sf == 0x07 || sf == 0x0a);
wire->beginTransmission(i2cAdds);
// CMD = 0, TRANSACTION = 11
if (wire->write(0x01100000 | sf) != 1) {
LOGERROR("write failed");
return false;
}
if (wire->endTransmission() != 0) {
LOGERROR("endtx failed");
return false;
}
return true;
}
Here's an example sketch, which outputs various values to the Serial port. Note that it uses sprintf() with float support, if you don't have this you can use these floating point routines.
And now for something completely different - a few cheap-o (or expensive-o) analog sensors...
Note that a simple Light Dependent Resistor LDR is often just as good, see my comments below.
DFRobot Ambient Light Sensor SKU DFR0026
Not recommended.
This module uses an analog phototransistor. It uses a "PT550 compatible" chip. Originally the PT550 was made by Sharp, but it's now discontinued. See the references links below. For some reason this module is more expensive than the excellent BH1750 I2C digital sensor.
The module's output can be connected directly to an analog input. It runs on 3.3 or 5V. The documentation says it reads up to 6000 lux, but my tests showed its maximum output voltage (saturation) occurred at about 1850 lux (LED lighting), and its output was not linear below 40 or above 1700 lux. Powering it with 3.3V or 5V did not affect the range.
The module's circuit uses a very simple voltage divider, either in 'common collector' or 'common emitter' mode, see the schematics below. The 10K series resistor R2 may be too high, limiting the max. current to 330uA at 3.3V. Is that OK?
To save a lot of messing about (unless you like that sort of thing), maybe use a Light Dependent Resistor (LDR) instead of this sodule. LDRs have a far wider range, and you can buy dozens of them for the price of this podule. See Using a cheap LDR.
One good point is that it is reasonably linear within the range 40..1800 lux, so you can use a simple re-scaler to convert analog input counts to lux. But only if the very limited lux range is suitable.
// Rescale from one linear range to another, e.g. from 0..1023 to 0..2000
// note: on a 32-bit machine use 'long long' (or int64_t) instead of 'long' to prevent multiplication overflow
int rescale(int value, int inmin, int inmax, int outmin, int outmax)
Note! The pin connections on your DFR0026 board may not match - and the circuits may be different too! (Mine were different, on both counts!)
There are several versions of this board with different connector pins and common collector or common emitter sensor wiring.
This is the schematic from the DFRobot website. It uses common emmitor mode and shows Pin 1 = VCC, Pin 2 = GND and Pin 3 = analog signal.
My DFRobot board uses common collector mode and has Pin 1 = analog signal S, Pin 2 = VCC and Pin 3 = GND!
(The bypass cap C1 is fitted but I forgot to draw it.)
But it doesn't really matter because we'll all be down the pub soon.
The connection diagram on the website shows this: Pin 1 = analog signal S, Pin 2 = VCC and Pin 3 = GND. (On my cable, the S signal wire was blue, not green.)
And I found this diagram, which again does not match the website's schematic. Bang! Thankfully, the board I have has the "new version" connector.
This is the most common analog sensor. It is better than the DFR0026, being a good quality Vishay TEMT6000 sensor, running on 3.3 or 5V, which can be connected directly to an analog input.
Its response is linear up to almost 3000 lux. You'll need a digital sensor (or LDR :-) to go higher.
Here's the code I used to get a pretty accurate lux reading with a 10-bit analog input. Note that it uses sprintf() with float support, if you don't have this you can use these floating point routines.
Routines to convert float to string and string to float (or double)
The floating point string conversion routines are large, and often controllers with small memories do not support them. Here are some methods to do the job for you. They are smaller and faster than the often-used dtostrf(). There are routines for converting doubles too, but you may not need them. If you have atof() you could use that, but it's quite big.
To keep the code as short as possible, the maximum value accepted is +-4294967000.0 (unsigned long minus rounding errors) if the value is larger it returns false with "NAN" in *psz.
The output format is 'n.n'. Exponents ('e+04' etc) are not handled. The max. number of decimal places is 6 for float and 8 for double. But note that float holds only 6 or 7 significant digits so the value may be truncated and rounded up.
// Mattlabs Floating Point Conversion Routines
// Copyright (C) muman.ch, 2025
// email: info@muman.ch
/*
NOTE On 8-bit processors double and float are both 32-bits (both float).
atof() takes 5192 bytes! That's a lot if you only have 32KB flash.
The AVR Arduinos do not have full support for converting floating point
numbers to ASCII strings. Functions like sprintf() and sscanf() will not
convert floating point. The following '***ToString' and '***FromString'
functions are optimized to be as fast as possible, way faster and smaller
than Arduino's dtostrf().
'psz' should should point to a buffer of at least 22 characters (including
NUL). Buffer overflow is NOT checked! I use 32, just to be safe.
The maximum value accepted for ****ToString() is +-4294967000.0
(unsigned long minus rounding errors) if the value is larger it returns
false with "NAN" in *result.
The output format is 'n.n'. Exponent format ('e+04' etc) is not handled.
The max. number of decimal places is 6 for float and 8 for double.
But note that float holds only 6 or 7 significant digits so the value
may be truncated and rounded up.
*/
#include <stdio.h>
#include <string.h>
#include <math.h>
#include <errno.h>
static const char utils_nan[] = "NAN"; // or "-E-" for 7-segment display
static const char utils_format1[] = "-%lu";
static const char utils_format2[] = "%lu";
static const char utils_format3[] = "-%lu.%0?lu";
static const char utils_format4[] = "%lu.%0?lu";
// Converts a float to a string with the format [-]1234567890[.123456]
// with max. 6 decimal places with 6 or 7 significant digits.
// The allowed range is +-4294967000.999999.
// Exponent format (e.g. "1e7") is not supported.
bool floatToString(float value, char* psz, unsigned int decimalPlaces)
{
const float MAX_FORMAT_FLOAT = 4294967000.0f;
bool negative = value < 0.0f;
if (negative)
value = -value;
// no decimal places, integer part only
if (decimalPlaces <= 0) {
float v = value + 0.5f; // round up
if (v > MAX_FORMAT_FLOAT) {
strcpy(psz, utils_nan);
return false;
}
sprintf(psz, negative ? utils_format1 : utils_format2, (unsigned long)v);
return true;
}
// max 6 decimal places for float
if (decimalPlaces > 6)
decimalPlaces = 6;
// integer part
if (value > MAX_FORMAT_FLOAT) {
strcpy(psz, utils_nan);
return false;
}
unsigned long left = (unsigned long)value;
// decimal part, multiply by 10^decimalPlaces to get an integer
float v = value - (float)left;
const float pwr10[6] =
{ 10.0f, 100.0f, 1000.0f, 10000.0f, 100000.0f, 1000000.0f };
float pwr = pwr10[decimalPlaces - 1];
v *= pwr;
v += 0.5f; // round up
if (v > pwr) { // handle overflow
v -= pwr;
left += 1;
}
unsigned long right = (unsigned long)v;
// format 'left.right' integers according to decimalPlaces
char dp = '0' + decimalPlaces;
char format[20];
if (negative) {
strcpy(format, utils_format3);
format[7] = dp;
}
else {
strcpy(format, utils_format4);
format[6] = dp;
}
sprintf(psz, format, left, right);
return true;
}
// Convert a string to a float
// String format: "[+|-]4294967295.123456"
// Exponents ('e+04' etc) are not handled.
// The integer part can be up to +-4294967295, the decimal part can be
// up to 6 digits, but note that float holds only 6 or 7 significant
// digits so it may be rounded/truncated.
// Returns false with *result = NAN if it fails to convert (bad format
// or overflow).
// *pszEnd points to the next unprocessed character.
bool stringToFloat(const char* psz, float* result, char** pszEnd)
{
*result = NAN;
errno = 0;
// skip leading spaces
while (*psz == ' ')
++psz;
// sign
char ch = *psz;
bool negative = ch == '-';
if (negative || ch == '+')
ch = *++psz;
// must be at least 1 digit
if (ch < '0' || ch > '9') {
*pszEnd = (char*)psz;
return false;
}
// integer part
char* endPtr1;
unsigned long left = strtoul(psz, &endPtr1, 10);
if (errno) // 'errno' is the only way to detect arithmetic overflow
return false;
float f = (float)left;
ch = *endPtr1;
if (ch != '.') {
*pszEnd = endPtr1;
}
else {
// decimal part
ch = *++endPtr1;
// must be at least 1 digit
if (ch < '0' || ch > '9') {
*pszEnd = endPtr1;
return false;
}
char* endPtr2;
unsigned long right = strtoul(endPtr1, &endPtr2, 10);
*pszEnd = endPtr2;
if (errno)
return false;
if (right != 0) {
unsigned int decimalPlaces = (unsigned int)(endPtr2 - endPtr1);
if (decimalPlaces > 6)
return false;
const float pwr10[6] =
{ 0.1f, 0.01f, 0.001f, 0.0001f, 0.00001f, 0.000001f };
f += (float)right * pwr10[decimalPlaces - 1];
}
}
*result = (negative && f != 0.0f) ? -f : f;
return true;
}
// Only if you need doubles, float and double are both floats on many MCUs
#if 1
// Converts a double to a string with the format [-]1234567890[.12345678]
// with max. 8 decimal places with 15..17 significant digits.
// The allowed range is +-4294967000.99999999.
// Exponent format (e.g. "1e7") is not supported.
bool doubleToString(double value, char* psz, unsigned int decimalPlaces)
{
const double MAX_FORMAT_DOUBLE = 4294967000.0;
#ifdef DEBUG
if (sizeof(double) == 4)
LOG_ERROR("Double is not supported");
#endif
bool negative = value < 0.0;
if (negative)
value = -value;
// no decimal places, integer part only
if (decimalPlaces <= 0) {
double v = value + 0.5; // round up
if (v > MAX_FORMAT_DOUBLE) {
strcpy(psz, utils_nan);
return false;
}
sprintf(psz, negative ? utils_format1 : utils_format1, (unsigned long)v);
return true;
}
// max 8 decimal places for double
if (decimalPlaces > 8)
decimalPlaces = 8;
// integer part
if (value > MAX_FORMAT_DOUBLE) {
strcpy(psz, utils_nan);
return false;
}
unsigned long left = (unsigned long)value;
// decimal part, multiply by 10^decimalPlaces to get an integer
double v = value - (double)left;
const double pwr10[8] =
{ 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 10000000.0, 100000000.0 };
double pwr = pwr10[decimalPlaces - 1];
v *= pwr;
v += 0.5f; // round up
if (v > pwr) { // handle overflow
v -= pwr;
left += 1;
}
unsigned long right = (unsigned long)v;
// format 'left.right' integers according to decimalPlaces
char dp = '0' + decimalPlaces;
char format[20];
if (negative) {
strcpy(format, utils_format3);
format[7] = dp;
}
else {
strcpy(format, utils_format4);
format[6] = dp;
}
sprintf(psz, format, left, right);
return true;
}
// Convert a string to a double
// String format: "[+|-]1234567890.12345678"
// Exponents ('e+04' etc) are not handled.
// The integer part can be up to +-4294967295, the decimal part can be
// up to 8 digits, but note that double holds 15..17 significant digits
// so it may be rounded/truncated.
// Returns false with *result = NAN if it fails to convert (bad format
// or overflow).
// *pszEnd points to the next unprocessed character.
bool stringToDouble(const char* psz, double* result, char** pszEnd)
{
#ifdef DEBUG
if (sizeof(double) == 4)
LOG_ERROR("Double is not supported");
#endif
*result = NAN;
errno = 0;
// skip leading spaces
while (*psz == ' ')
++psz;
// sign
char ch = *psz;
bool negative = ch == '-';
if (negative || ch == '+')
ch = *++psz;
// must be at least 1 digit
if (ch < '0' || ch > '9') {
*pszEnd = (char*)psz;
return false;
}
// integer part
char* endPtr1;
unsigned long left = strtoul(psz, &endPtr1, 10);
if (errno) // 'errno' is the only way to detect arithmetic overflow
return false;
double d = (double)left;
ch = *endPtr1;
if (ch != '.') {
*pszEnd = endPtr1;
}
else {
// decimal part
ch = *++endPtr1;
// must be at least 1 digit
if (ch < '0' || ch > '9') {
*pszEnd = endPtr1;
return false;
}
char* endPtr2;
unsigned long right = strtoul(endPtr1, &endPtr2, 10);
*pszEnd = endPtr2;
if (errno)
return false;
unsigned int decimalPlaces = (unsigned int)(endPtr2 - endPtr1);
if (decimalPlaces > 8)
return false;
const double pwr10[8] =
{ 0.1, 0.01, 0.001, 0.0001, 0.00001, 0.000001, 0.0000001, 0.00000001 };
d += (double)right * pwr10[decimalPlaces - 1];
}
*result = (negative && d != 0.0) ? -d : d;
return true;
}
#endif
<are you down the pub yet? - ed> <just give me 10 minutes to change out of my pyjamas - matt>