Using my ambient light sensor to work like true tone

3 minute read Published: 2025-10-17

I recently noticed that my laptop has an ambient light sensor. Turns out it also supports measuring the color temperature of my surrounding.

Table of Contents

Figuring out the light sensor

I am currently using an HP EliteBook 845 G10 with Fedora Workstation. In the Gnome settings I found an option for automatic screen brightness under Power > Power Saving > Automatic Screen Brightness, which piqued my interest. Turns out it does not work. Oh well. After a quick search I found out where this sensor is located in sysfs. In my case it's /sys/bus/iio/devices/iio\:device0. IIO is the Industrial I/O subsystem, which is intended for devices that in some sense perform either analog-to-digital conversion (ADC) or digital-to-analog conversion (DAC) or both.

Looking at the endpoints of this devices gives us the following:
  • buffer0
  • buffer
  • current_timestamp_clock
  • dev
  • in_chromaticity_hysteresis
  • in_chromaticity_hysteresis_relative
  • in_chromaticity_offset
  • in_chromaticity_sampling_frequency
  • in_chromaticity_scale
  • in_chromaticity_x_raw
  • in_chromaticity_y_raw
  • in_colortemp_hysteresis
  • in_colortemp_hysteresis_relative
  • in_colortemp_offset
  • in_colortemp_raw
  • in_colortemp_sampling_frequency
  • in_colortemp_scale
  • in_illuminance_hysteresis
  • in_illuminance_offset
  • in_illuminance_hysteresis_relative
  • in_illuminance_raw
  • in_illuminance_sampling_frequency
  • in_illuminance_scale
  • in_intensity_both_raw
  • in_intensity_hysteresis
  • in_intensity_hysteresis_relative
  • in_intensity_offset
  • in_intensity_sampling_frequency
  • in_intensity_scale
  • name
  • power
  • scan_elements
  • subsystem
  • trigger
  • uevent

The file name returns als, which I assume stands for ambient light sensor. So far, so good. We can take a look at the raw sysfs files listed above. Chromaticity does not seem to be supported by my sensor. Colortemp is returned at in_colortemp_raw in Kelvin, I assume. Illuminance is reported at in_illuminance_raw. The *_scale is set to 0.1 for both of these. Which might make sense for the illuminance but might be wrong for the color temperature if we assume that it is reported in Kelvin.

The iio-sensor-proxy is used to proxy sensor data from accelerometers, light sensors, and compasses to applications through D-Bus. With the included programm monitor-sensor we are able to read this data from D-Bus. For a raw illuminance value of 80, we get an illuminance of 8.0 lux.

Controlling the display color

Changing the color temperature of the display with post-processing is already implemented in GNOME with the night light functionality, which I wanted to control through D-Bus. Since I didn't get any further than reading out the current value, I asked Grok Code Fast 1 for a solution, expecting maybe another option. It then gave me a Python program that basically worked out of the box. It only got the type and expected range of the color wrong, which was quickly fixed.

Here it is (with some sligth modifications):
#!/usr/bin/env python3

import os
import time
import subprocess
import sys

# Sensor path
SENSOR_DIR = "/sys/bus/iio/devices/iio:device0"
RAW_FILE = os.path.join(SENSOR_DIR, "in_colortemp_raw")
SCALE_FILE = os.path.join(SENSOR_DIR, "in_colortemp_scale")

# GNOME Night Light settings
NIGHT_LIGHT_SCHEMA = "org.gnome.settings-daemon.plugins.color"
ENABLED_KEY = "night-light-enabled"
TEMPERATURE_KEY = "night-light-temperature"
SCHEDULE_KEY = "night-light-schedule-automatic"

# Mapping parameters (adjust based on your sensor's typical range)
MIN_AMBIENT_K = 2000.0  # Warmest ambient light (e.g., incandescent)
MAX_AMBIENT_K = 6500.0  # Coolest ambient light (e.g., daylight)
MIN_SCREEN_VAL = 6500  # Cool screen (matches cool ambient)
MAX_SCREEN_VAL = 4000  # Warm screen (matches warm ambient)


def read_sensor_value(file_path):
    """Read raw value from sysfs file."""
    try:
        with open(file_path, "r") as f:
            return float(f.read().strip())
    except (IOError, ValueError) as e:
        print(f"Error reading {file_path}: {e}", file=sys.stderr)
        return None


def get_color_temp():
    """Get color temperature in Kelvin from sensor."""
    raw = read_sensor_value(RAW_FILE)
    if raw is None:
        return None

    scale = 1.0  # Default if no scale file
    # commented out due to scale factor being reported wrong
    # scale_val = read_sensor_value(SCALE_FILE)
    # if scale_val is not None:
    #     # Parse scale (e.g., "0.0625" or "1 0.001" for integer + fractional)
    #     if " " in str(scale_val):
    #         int_part, frac_part = str(scale_val).split()
    #         scale = float(int_part) + float(frac_part)
    #     else:
    #         scale = float(scale_val)

    return raw * scale


def map_to_screen_temp(ambient_k):
    if ambient_k is None:
        return None
    # Clamp to expected range
    ambient_k = max(MIN_AMBIENT_K, min(MAX_AMBIENT_K, ambient_k))
    # Linear map: high K (cool ambient) -> low screen val (cool screen)
    # low K (warm ambient) -> high screen val (warm screen)
    normalized = (MAX_AMBIENT_K - ambient_k) / (MAX_AMBIENT_K - MIN_AMBIENT_K)
    return MIN_SCREEN_VAL + normalized * (MAX_SCREEN_VAL - MIN_SCREEN_VAL)


def update_night_light(temp_val):
    """Update GNOME Night Light via gsettings."""
    try:
        # Enable Night Light
        subprocess.run(
            ["gsettings", "set", NIGHT_LIGHT_SCHEMA, ENABLED_KEY, "true"], check=True
        )
        # Disable automatic scheduling to keep it always on
        subprocess.run(
            ["gsettings", "set", NIGHT_LIGHT_SCHEMA, SCHEDULE_KEY, "false"], check=True
        )
        # Set temperature (0.0 to 1.0)
        subprocess.run(
            [
                "gsettings",
                "set",
                NIGHT_LIGHT_SCHEMA,
                TEMPERATURE_KEY,
                str(int(temp_val)),
            ],
            check=True,
        )
        print(
            f"Updated screen temperature to {temp_val:.2f} (ambient ~{get_color_temp()}K)"
        )
    except subprocess.CalledProcessError as e:
        print(f"Error updating gsettings: {e}", file=sys.stderr)


def main():
    print("Starting True Tone-like adaptation...")
    print(f"Monitoring sensor at {RAW_FILE}")
    if os.path.exists(SCALE_FILE):
        print(f"Scale file found at {SCALE_FILE}")

    while True:
        ambient_k = get_color_temp()
        if ambient_k is not None:
            # just do 1:1 mapping here
            # screen_val = map_to_screen_temp(ambient_k)
            screen_val = ambient_k
            if screen_val is not None:
                update_night_light(screen_val)
        else:
            print("Skipping update: could not read sensor", file=sys.stderr)

        time.sleep(1)  # Check every 5 seconds (adjust as needed)


if __name__ == "__main__":
    # Check if sensor files exist
    if not os.path.exists(RAW_FILE):
        print(f"Sensor file not found: {RAW_FILE}", file=sys.stderr)
        print("Verify the device path with 'ls /sys/bus/iio/devices/'", file=sys.stderr)
        sys.exit(1)

    main()

'Testing'

The code above maps the sensor reading 1:1 to the night light's configured temperature. By default (night light off), it is set to 6500K. I found out we can go even higher than that (up to around 10,000 K). The lower end is somewhere around 1000K. I recorded a quick video of it working with a fixed white balance.

None of this can be considered practical, and integrating it into GNOME would be quite the undertaking, I assume. I'm also sure the algorithms (and sensors?) are quite a bit more sophisticated than this implementation :).