Home Posts Post Search Tag Search

Weather App 06 - LTR390-UV-1
Published on: 2025-08-13 Tags: elixir, Blog, Side Project, Libraries, Nerves, Weather App, Poncho

This is the work I did to create the sensor package for the LTR390 UV sensor.

Include Dependencies for Sensors

        Now we need to include the right dependencies for the other sensors that we have on the hat. In the book they talk about using some predefined libs :bmp280 and :sgp30 which will take some the needed functions in order to use the sensors. I might need to define my own but Ill see about using some libs if I can find them.

        In order to make this work with the current settings I will write my own conn.ex, config.ex and sensor_name.ex files. Keep in mind you will need to add in the dependencies to the sensor hub after you make them in order to add them to the Supervisor tree.

        conn.ex
            This is where I took a lot of the existing code and added in some of the new addresses and made sure that I set all the correct values in the config.

            @command_bit 0x80
            @enable_register 0x00
            @gain_register 0x05
            @control_register 0x04
            @als_data_register [0x0D, 0x0E, 0x0F]
            @uvs_data_register [0x10, 0x11, 0x12]

            def write_config(config, i2c, sensor) do
                enable_byte = Config.to_enable_byte(config)
                control_byte = Config.to_control_byte(config)
                gain_byte = Config.to_gain_byte(config)

                # Write ENABLE register (1 byte) with command bit set
                I2C.write(i2c, sensor, <<@command_bit ||| @enable_register, enable_byte>>)

                # Write CONTROL register (1 byte) with command bit set
                I2C.write(i2c, sensor, <<@command_bit ||| @control_register, control_byte>>)

                # Write the GAIN register
                I2C.write(i2c, sensor, <<@command_bit ||| @gain_register, gain_byte>>)
            end

            The sensor for UV has two types of sensors so I needed to be sure that I set both of them and could read from either.

            def read(i2c, sensor, %Config{uvs_als: :uvs} = config) do
                <<low, mid, high>> =
                I2C.write_read!(i2c, sensor, <<@command_bit ||| hd(@uvs_data_register)>>, 3)

                raw = low ||| mid <<< 8 ||| high <<< 16
                Config.uvs_to_uvi(config, raw)
            end

            def read(i2c, sensor, %Config{uvs_als: :als} = config) do
                <<low, mid, high>> =
                I2C.write_read!(i2c, sensor, <<@command_bit ||| hd(@als_data_register)>>, 3)

                # Convert using ALS formula when you fix it
                raw = low ||| mid <<< 8 ||| high <<< 16
                Config.als_to_lux(config, raw, 0)
            end

        config.ex
            This is where I had to set some new values for the struct and because there are some hardcoded formula that I would use for the conversion I set those values here too.

            @uv_sensitivity_base 2300.0

            defstruct resolution: :res_default,
                        measure_rate: :measure_rate_100_ms,
                        uvs_als: :uvs,
                        gain: :low,
                        reset: false

            def new, do: struct(__MODULE__)

            def new(opts) do
                struct(__MODULE__, opts)
            end

            defp resolution(:res_20bit), do: 0b000
            defp resolution(:res_19bit), do: 0b001
            defp resolution(:res_18bit), do: 0b010
            defp resolution(:res_17bit), do: 0b011
            defp resolution(:res_16bit), do: 0b100
            defp resolution(:res_15bit), do: 0b101
            defp resolution(:res_default), do: 0b010

            defp measure_rate(:measure_rate_25_ms), do: 0b000
            defp measure_rate(:measure_rate_50_ms), do: 0b001
            defp measure_rate(:measure_rate_100_ms), do: 0b010
            defp measure_rate(:measure_rate_200_ms), do: 0b011
            defp measure_rate(:measure_rate_500_ms), do: 0b100
            defp measure_rate(:measure_rate_1000_ms), do: 0b101
            defp measure_rate(:measure_rate_2000_ms), do: 0b110
            defp measure_rate(:measure_rate_default), do: 0b010

            defp gain(:min), do: 0b000
            defp gain(:low), do: 0b001
            defp gain(:med), do: 0b010
            defp gain(:high), do: 0b011
            defp gain(:max), do: 0b100
            defp gain(:gain_default), do: 0b001
            defp uvs_als(:uvs), do: 1
            defp uvs_als(:als), do: 0
            defp reset(true), do: 1
            defp reset(_), do: 0

            def to_control_byte(%__MODULE__{resolution: resolution, measure_rate: measure_rate}) do
                resolution(resolution) <<< 4 ||| measure_rate(measure_rate)
            end

            def to_gain_byte(%__MODULE__{gain: gain}) do
                gain(gain)
            end

            def to_enable_byte(%__MODULE__{uvs_als: uvs_als, reset: reset}) do
                reset_bit_rate = reset(reset) <<< 4
                uvs_als_bit_rate = uvs_als(uvs_als) <<< 3
                enable_bit_rate = 1 <<< 1
                reserve_bit_rate = 1 <<< 0

                reset_bit_rate ||| uvs_als_bit_rate ||| enable_bit_rate |||
                reserve_bit_rate
            end

            defp integration_time_ms(:measure_rate_25_ms), do: 25
            defp integration_time_ms(:measure_rate_50_ms), do: 50
            defp integration_time_ms(:measure_rate_100_ms), do: 100
            defp integration_time_ms(:measure_rate_200_ms), do: 200
            defp integration_time_ms(:measure_rate_500_ms), do: 500
            defp integration_time_ms(:measure_rate_1000_ms), do: 1000
            defp integration_time_ms(:measure_rate_2000_ms), do: 2000
            defp integration_time_ms(:measure_rate_default), do: 100

            def gain_multiplier(:min), do: 1
            def gain_multiplier(:low), do: 3
            def gain_multiplier(:med), do: 6
            def gain_multiplier(:high), do: 9
            def gain_multiplier(:max), do: 18

            def als_to_lux(%{gain: gain, measure_rate: measure_rate}, raw_counts, wfac \\ 1.0) do
                int_ms = integration_time_ms(measure_rate)
                raw_counts * 0.6 / (gain_multiplier(gain) * (int_ms / 100)) * wfac
            end

            def uvs_to_uvi(%{gain: gain, measure_rate: measure_rate}, raw_counts, wfac \\ 1.0) do
                int_ms = integration_time_ms(measure_rate)
                sensitivity = @uv_sensitivity_base * (gain_multiplier(gain) / 18) * (int_ms / 400)
                raw_counts / sensitivity * wfac
            end

        LTR390_UV.ex
            Again very similar to the other sensor but we have to get 2 types of measurements so I had to implement those.

            @impl true
            def init(%{address: address, i2c_bus_name: bus_name} = args) do
                i2c = Comm.open(bus_name)

                config =
                args
                |> Map.take([:gain, :resolution, :uvs_als, :measure_rate, :reset])
                |> Config.new()

                Comm.write_config(config, i2c, address)

                # Calculate measure rate in milliseconds (example mapping)
                measure_rate =
                case config.measure_rate do
                    :it_25_ms -> 25
                    :it_50_ms -> 50
                    :it_100_ms -> 100
                    :it_200_ms -> 200
                    :it_500_ms -> 500
                    :it_1000_ms -> 1000
                    :it_2000_ms -> 2000
                    _ -> 100
                end

                # Schedule first measure after measure_rate
                Process.send_after(self(), :measure, measure_rate)

                state = %{
                i2c: i2c,
                address: address,
                config: config,
                resolution: config.resolution,
                measure_rate: measure_rate,
                reset: config.reset,
                gain: config.gain,
                uvs_als: config.uvs_als,
                last_uvs: :no_reading,
                last_als: :no_reading
                }

                {:ok, state}
            end

            def init(args) do
                {bus_name, address} = Comm.discover()
                transport = "bus: #{bus_name}, address: #{address}"
                Logger.info("Starting LTR390_UV. Please specify an address and a bus.")
                Logger.info("Starting on " <> transport)

                defaults =
                args
                |> Map.put(:address, address)
                |> Map.put(:i2c_bus_name, bus_name)

                init(defaults)
            end

            @doc """
            This are set to take the last reading from the sensor, then update the reading with the new reading and
            appending it to the state. Once that is done it will switch to the other type of sensor and schedule an other
            reading.
            """
            @impl true
            def handle_info(
                    :measure,
                    %{
                    i2c: i2c,
                    address: address,
                    config: config,
                    measure_rate: measure_rate,
                    uvs_als: current_mode
                    } = state
                ) do
                last_reading = Comm.read(i2c, address, config)

                # Toggle mode and update config accordingly
                {new_state, new_config} =
                case current_mode do
                    :uvs ->
                    {%{state | last_uvs: last_reading, uvs_als: :als}, %{config | uvs_als: :als}}

                    :als ->
                    {%{state | last_als: last_reading, uvs_als: :uvs}, %{config | uvs_als: :uvs}}
                end

                # Write new config to switch sensor mode
                Comm.write_config(new_config, i2c, address)

                # Schedule next measurement after full measure_rate
                Process.send_after(self(), :measure, measure_rate)

                {:noreply, %{new_state | config: new_config}}
            end

            @doc """
            This is called when the GenServer is asked to get the last measurements for both uvs and als.
            """
            @impl true
            def handle_call(:get_measurement, _from, state) do
                last_uvs = state.last_uvs
                last_als = state.last_als
                {:reply, {last_uvs, last_als}, state}
            end

            @impl true
            def terminate(_reason, %{i2c: i2c}) do
                Circuits.I2C.close(i2c)
                :ok
            end

        I plan on finishing all the other sensors at some point and will be adding in the libs to gihub after I finish each one.