defmodule Exddcutil do @moduledoc """ Elixir wrapper for the `ddcutil` tool to control monitor settings via DDC/CI. This library provides a high-level interface to query and modify monitor settings on Linux systems using the DDC/CI protocol through the `ddcutil` command-line tool. ## Prerequisites - Linux system with `ddcutil` installed - DDC/CI compatible monitor(s) with DDC/CI enabled in the OSD ## Testing Status This library has been tested with: - Dell P2715Q Other DDC/CI compatible monitors should work, but behavior may vary depending on manufacturer implementation. ## Basic Usage # Detect available displays {:ok, displays} = Exddcutil.detect() # Get display capabilities {:ok, caps} = Exddcutil.capabilities(1) # Set brightness :ok = Exddcutil.set_brightness(1, 75) # Get current brightness {:ok, %{current: 75, max: 100}} = Exddcutil.get_brightness(1) Most functions take a display index (1-based) as the first argument, as returned by `detect/1`. """ alias Exddcutil.{VCP, Parser, SystemRunner} @type runner_opt :: {:runner, module()} @type display_index :: pos_integer() @type bus_index :: pos_integer() @type target_opt :: {:display, display_index()} | {:bus, bus_index()} @type common_opt :: runner_opt | target_opt # Public API @doc """ Detects all available displays connected to the system. Returns a list of maps containing display information including index, bus number, model, vendor, and serial number. ## Examples iex> Exddcutil.detect() {:ok, [ %{index: 1, bus: 5, model: "DELL P2715Q", vendor: "DEL", serial: "ABC123"} ]} """ @spec detect([runner_opt()]) :: {:ok, list()} | {:error, any()} def detect(opts \\ []) do with {:ok, raw} <- run_ddc(["detect"], opts) do {:ok, Parser.parse_detect(raw)} end end @doc """ Gets the full capabilities of a display. Returns a map containing model, vendor, serial, and available features with their values. ## Examples iex> Exddcutil.capabilities(1) {:ok, %{ model: "DELL P2715Q", vendor: "DEL", features: %{ "Brightness" => %{code: 16, values: %{range: {0, 100}}}, "Input Source" => %{code: 96, values: [%{code: "0x11", name: "HDMI-1"}]} }, input_sources: [%{code: "0x11", name: "HDMI-1"}] }} """ @spec capabilities(display_index(), [common_opt()]) :: {:ok, map()} | {:error, any()} def capabilities(display_index, opts \\ []) do with {:ok, raw} <- run_ddc(["capabilities"] ++ addressing_args(display_index, opts), opts) do {:ok, Parser.parse_capabilities(raw)} end end @doc """ Lists available input sources from display capabilities. Returns a list of maps with code and name for each input source. ## Examples iex> Exddcutil.input_sources(1) {:ok, [ %{code: "0x11", name: "HDMI-1"}, %{code: "0x0f", name: "DisplayPort-1"} ]} """ @spec input_sources(display_index(), [common_opt()]) :: {:ok, list()} | {:error, any()} def input_sources(display_index, opts \\ []) do case capabilities(display_index, opts) do {:ok, caps} -> {:ok, Map.get(caps, :input_sources, [])} {:error, reason} -> {:error, reason} end end @doc """ Lists available input sources with an active flag indicating the current source. Combines `input_sources/2` and `get_input_source/2` to mark which input is currently active. ## Examples iex> Exddcutil.get_input_sources(1) {:ok, [ %{code: "0x11", name: "HDMI-1", active: false}, %{code: "0x0f", name: "DisplayPort-1", active: true} ]} """ @spec get_input_sources(display_index(), [common_opt()]) :: {:ok, list()} | {:error, any()} def get_input_sources(display_index, opts \\ []) do with {:ok, sources} <- input_sources(display_index, opts), {:ok, current} <- get_input_source(display_index, opts) do current_code = current[:current] sources_with_active = Enum.map(sources, fn source -> Map.put(source, :active, source[:code] == current_code) end) {:ok, sources_with_active} end end @doc """ Sets the display brightness. ## Parameters - `display_index` - Display index (1-based) - `value` - Brightness value (typically 0..100, check capabilities for range) - `opts` - Optional keyword list (runner, bus) ## Examples iex> Exddcutil.set_brightness(1, 75) :ok """ @spec set_brightness(display_index(), non_neg_integer(), [common_opt()]) :: :ok | {:error, any()} def set_brightness(display_index, value, opts \\ []) do set_vcp(display_index, VCP.brightness(), value, opts) end @doc """ Sets the display contrast. ## Examples iex> Exddcutil.set_contrast(1, 50) :ok """ @spec set_contrast(display_index(), non_neg_integer(), [common_opt()]) :: :ok | {:error, any()} def set_contrast(display_index, value, opts \\ []) do set_vcp(display_index, VCP.contrast(), value, opts) end @doc "Sets the red gain/drive." @spec set_red(display_index(), non_neg_integer(), [common_opt()]) :: :ok | {:error, any()} def set_red(display_index, value, opts \\ []) do set_vcp(display_index, VCP.red(), value, opts) end @doc "Sets the green gain/drive." @spec set_green(display_index(), non_neg_integer(), [common_opt()]) :: :ok | {:error, any()} def set_green(display_index, value, opts \\ []) do set_vcp(display_index, VCP.green(), value, opts) end @doc "Sets the blue gain/drive." @spec set_blue(display_index(), non_neg_integer(), [common_opt()]) :: :ok | {:error, any()} def set_blue(display_index, value, opts \\ []) do set_vcp(display_index, VCP.blue(), value, opts) end @doc "Sets the screen orientation. Note: may be read-only on some monitors." @spec set_screen_orientation(display_index(), term(), [common_opt()]) :: :ok | {:error, any()} def set_screen_orientation(display_index, orientation, opts \\ []) do code = cond do is_integer(orientation) -> orientation is_binary(orientation) and String.starts_with?(orientation, "0x") -> orientation true -> orientation end set_vcp(display_index, VCP.screen_orientation(), code, opts) end @doc "Sets the display mode." @spec set_display_mode(display_index(), term(), [common_opt()]) :: :ok | {:error, any()} def set_display_mode(display_index, mode, opts \\ []) do code = cond do is_integer(mode) -> mode is_binary(mode) and String.starts_with?(mode, "0x") -> mode true -> mode end set_vcp(display_index, VCP.display_mode(), code, opts) end @doc """ Sets the input source. Accepts either a source name (e.g., "HDMI-1"), hex code (e.g., "0x11"), or integer code. ## Examples iex> Exddcutil.set_input_source(1, "DisplayPort-1") :ok iex> Exddcutil.set_input_source(1, "0x0f") :ok """ @spec set_input_source(display_index(), term(), [common_opt()]) :: :ok | {:error, any()} def set_input_source(display_index, source, opts \\ []) do case capabilities(display_index, opts) do {:ok, caps} -> code = cond do is_integer(source) -> source is_binary(source) and String.starts_with?(source, "0x") -> source true -> caps |> Map.get(:input_sources, []) |> Enum.find_value(fn %{name: name, code: code} -> if String.downcase(name) == String.downcase(to_string(source)), do: code, else: nil end) end set_vcp(display_index, VCP.input_source(), code, opts) {:error, reason} -> {:error, reason} end end @doc """ Sets the color preset. ## Examples iex> Exddcutil.set_color_preset(1, 0x05) :ok iex> Exddcutil.set_color_preset(1, "0x05") :ok """ @spec set_color_preset(display_index(), term(), [common_opt()]) :: :ok | {:error, any()} def set_color_preset(display_index, value, opts \\ []) do set_vcp(display_index, VCP.color_preset(), value, opts) end @doc """ Sets an arbitrary VCP code to a value. ## Examples iex> Exddcutil.set_vcp(1, 0x14, 6500) # Color temperature :ok """ @spec set_vcp(display_index(), non_neg_integer() | String.t(), non_neg_integer() | String.t(), [ common_opt() ]) :: :ok | {:error, any()} def set_vcp(display_index, code, value, opts \\ []) do args = ["setvcp", VCP.to_code_arg(code), VCP.to_value_arg(value)] ++ addressing_args(display_index, opts) case run_ddc(args, opts) do {:ok, _} -> :ok {:error, reason} -> {:error, reason} end end @doc """ Reads an arbitrary VCP code. Returns a map with code, name, current value, and optionally max value and current label. ## Examples iex> Exddcutil.get_vcp(1, 0x10) {:ok, %{code: "0x10", name: "Brightness", current: 75, max: 100}} """ @spec get_vcp(display_index(), non_neg_integer() | String.t(), [common_opt()]) :: {:ok, map()} | {:error, any()} def get_vcp(display_index, code, opts \\ []) do with {:ok, raw} <- run_ddc( ["getvcp", VCP.to_code_arg(code)] ++ addressing_args(display_index, opts), opts ) do {:ok, Parser.parse_getvcp(raw)} end end @doc "Gets the current brightness setting." @spec get_brightness(display_index(), [common_opt()]) :: {:ok, map()} | {:error, any()} def get_brightness(display_index, opts \\ []) do get_vcp(display_index, VCP.brightness(), opts) end @doc "Gets the current contrast setting." @spec get_contrast(display_index(), [common_opt()]) :: {:ok, map()} | {:error, any()} def get_contrast(display_index, opts \\ []) do get_vcp(display_index, VCP.contrast(), opts) end @doc "Gets the current red gain/drive." @spec get_red(display_index(), [common_opt()]) :: {:ok, map()} | {:error, any()} def get_red(display_index, opts \\ []) do get_vcp(display_index, VCP.red(), opts) end @doc "Gets the current green gain/drive." @spec get_green(display_index(), [common_opt()]) :: {:ok, map()} | {:error, any()} def get_green(display_index, opts \\ []) do get_vcp(display_index, VCP.green(), opts) end @doc "Gets the current blue gain/drive." @spec get_blue(display_index(), [common_opt()]) :: {:ok, map()} | {:error, any()} def get_blue(display_index, opts \\ []) do get_vcp(display_index, VCP.blue(), opts) end @doc "Gets the current screen orientation." @spec get_screen_orientation(display_index(), [common_opt()]) :: {:ok, map()} | {:error, any()} def get_screen_orientation(display_index, opts \\ []) do get_vcp(display_index, VCP.screen_orientation(), opts) end @doc """ Gets the current input source. Returns a map with the current source code and label. ## Examples iex> Exddcutil.get_input_source(1) {:ok, %{code: "0x60", name: "Input Source", current: "0x0f", current_label: "DisplayPort-1"}} """ @spec get_input_source(display_index(), [common_opt()]) :: {:ok, map()} | {:error, any()} def get_input_source(display_index, opts \\ []) do get_vcp(display_index, VCP.input_source(), opts) end @doc "Gets the current color preset/temperature." @spec get_color_preset(display_index(), [common_opt()]) :: {:ok, map()} | {:error, any()} def get_color_preset(display_index, opts \\ []) do get_vcp(display_index, VCP.color_preset(), opts) end @doc "Gets the current display mode." @spec get_display_mode(display_index(), [common_opt()]) :: {:ok, map()} | {:error, any()} def get_display_mode(display_index, opts \\ []) do get_vcp(display_index, VCP.display_mode(), opts) end defp addressing_args(display_index, opts) do [] |> maybe_put_opt("--display", display_index) |> maybe_put_opt("--bus", opts[:bus]) end defp maybe_put_opt(args, _flag, nil), do: args defp maybe_put_opt(args, flag, value), do: args ++ [flag, to_string(value)] @spec run_ddc([String.t()], [common_opt()]) :: {:ok, String.t()} | {:error, any()} def run_ddc(args, opts \\ []) do runner = opts[:runner] || Application.get_env(:exddcutil, :runner, SystemRunner) case runner.run(args) do {:ok, out} -> {:ok, out} {:error, reason} -> {:error, reason} end end end