Current section

Files

Jump to
delux lib delux.ex
Raw

lib/delux.ex

defmodule Delux do
@moduledoc File.read!("README.md")
|> String.split("<!-- MODULEDOC -->")
|> Enum.fetch!(1)
use GenServer
alias Delux.Effects
alias Delux.Glue
alias Delux.Program
@default_priority :status
@default_priorities [:status, :notification, :user_feedback]
@default_indicator :default
@default_indicator_config %{default: %{}}
@default_led_path "/sys/class/leds"
@typedoc """
Priority of an indicator program
Priorities determine which program is rendered when more than one can be
shown at the same time. The default priority is `:status` which is also the
lowest priority. The `:notification` and `:user_feedback` priorities are
higher. For example, rendering visual feedback to the user pressing a button
can be assigned to the `:user_feedback` priority so the user knows that the
button pressed worked regardless of what else is happening.
"""
@type priority() :: atom()
@typedoc """
The name for one indicator
An indicator may be composed of multiple LEDs, but they're arranged such that
it looks like one light source to someone looking at it. For example, an RGB
LED has 3 LEDs inside of it.
These can be anything you want. If you don't explicitly specify indicator
names, an indicator named `:default` is used.
"""
@type indicator_name() :: atom()
@typedoc """
Configuration for an indicator
Specify the Linux LED name for each LED. Single LED indicators should use a
color that's close or just choose `:red`.
"""
@type indicator_config() :: %{
optional(:red) => String.t(),
optional(:green) => String.t(),
optional(:blue) => String.t()
}
@typedoc """
Delux configuration options
* `:led_path` - the path to the LED directories (defaults to `"/sys/class/leds"`)
* `:priorities` - a list of priority atoms from lowest to highest. Defaults to `[:status, :notification, :user_feedback]`
* `:indicators` - a map of indicator names to their configurations
* `:name` - register the Delux GenServer using this name. Defaults to `Delux`. Specify `nil` to not register a name.
"""
@type options() :: [
led_path: String.t(),
priorities: [priority()],
indicators: %{indicator_name() => indicator_config()},
name: atom() | nil
]
@doc """
Start an Delux GenServer
See `t:options()` for configuration options
"""
@spec start_link(options()) :: GenServer.on_start()
def start_link(options) do
genserver_options =
case Keyword.fetch(options, :name) do
{:ok, nil} -> []
{:ok, name} -> [name: name]
:error -> [name: __MODULE__]
end
GenServer.start_link(__MODULE__, options, genserver_options)
end
@doc """
Update one or more indicators to a new program
Passing `nil` for the program removes the program running at the specified
priority. This is the same as calling `clear/2`.
"""
@spec render(
GenServer.server(),
%{indicator_name() => Program.t() | nil} | Program.t() | nil,
priority()
) :: :ok
def render(server \\ __MODULE__, program, priority \\ @default_priority)
def render(server, %Program{} = program, priority) when is_atom(priority) do
with {:error, reason} <-
GenServer.call(server, {:render, priority, %{@default_indicator => program}}) do
raise reason
end
end
def render(server, indicator_program_map, priority)
when is_map(indicator_program_map) and is_atom(priority) do
with {:error, reason} <-
GenServer.call(server, {:render, priority, indicator_program_map}) do
raise reason
end
end
def render(server, nil, priority) when is_atom(priority) do
clear(server, priority)
end
@doc """
Clear out any programs set at the specified priority
If this means that no programs at any priority are set, the indicator is
turned off.
"""
@spec clear(GenServer.server(), priority()) :: :ok
def clear(server \\ __MODULE__, priority \\ @default_priority) when is_atom(priority) do
with {:error, reason} <- GenServer.call(server, {:clear, priority}) do
raise reason
end
end
@doc """
Adjust the overall brightness of all indicators
Effects are adjusted based on the value passed.
NOTE: This is not fully supported yet!
"""
@spec adjust_brightness(GenServer.server(), 0..100) :: :ok
def adjust_brightness(server \\ __MODULE__, percent) when percent >= 0 and percent <= 100 do
GenServer.call(server, {:adjust_brightness, percent})
end
@doc """
Print out info about an indicator
This is handy when you can't physically see an indicator. It's intended for
users at the IEx prompt. For programmatic use, see `info_as_ansidata/2`.
"""
@spec info(GenServer.server(), indicator_name()) :: IO.ANSI.ansidata()
def info(server \\ __MODULE__, indicator \\ @default_indicator) do
info_as_ansidata(server, indicator) |> IO.ANSI.format() |> IO.puts()
end
@doc """
Return user-readable information about an indicator
"""
@spec info_as_ansidata(GenServer.server(), indicator_name()) :: IO.ANSI.ansidata()
def info_as_ansidata(server \\ __MODULE__, indicator \\ @default_indicator) do
case GenServer.call(server, {:info, indicator}) do
{:ok, result} -> result
{:error, reason} -> raise reason
end
end
@typedoc false
@type state() :: %{
glue: %{indicator_name() => Glue.state()},
priorities: [priority()],
brightness: 0..100,
active: %{priority() => %{indicator_name() => Program.t()}},
all_off: %{indicator_name() => Program.t()},
timers: %{priority() => {reference(), reference()}},
indicator_names: [indicator_name()]
}
@impl GenServer
def init(options) do
priorities = options[:priorities] || @default_priorities
indicator_configs = options[:indicators] || @default_indicator_config
led_path = options[:led_path] || @default_led_path
off = Effects.off()
all_off = for {name, _config} <- indicator_configs, do: {name, off}
state = %{
glue: open_indicators(led_path, indicator_configs),
indicator_names: Map.keys(indicator_configs),
priorities: priorities,
active: %{},
brightness: 100,
all_off: Map.new(all_off),
timers: %{}
}
refresh_indicators(state)
{:ok, state}
end
@impl GenServer
def handle_call({:render, priority, indicators}, _from, state) do
case do_render(state, priority, indicators) do
{:ok, new_state} -> {:reply, :ok, new_state}
error -> {:reply, error, state}
end
end
def handle_call({:clear, priority}, _from, state) do
case do_clear(state, priority) do
{:ok, new_state} -> {:reply, :ok, new_state}
error -> {:reply, error, state}
end
end
def handle_call({:adjust_brightness, percent}, _from, state) do
new_state = %{state | brightness: percent}
refresh_indicators(new_state)
{:reply, :ok, new_state}
end
def handle_call({:info, indicator}, _from, state) do
result =
case summarize_programs(state)[indicator] do
nil -> {:error, %ArgumentError{message: "Invalid indicator #{inspect(indicator)}"}}
program -> {:ok, Program.ansi_description(program)}
end
{:reply, result, state}
end
defp remove_nil_values(m) do
for {k, v} <- m, v != nil, reduce: %{} do
acc -> Map.put(acc, k, v)
end
end
defp merge_indicator_program(nil, new_mapping), do: remove_nil_values(new_mapping)
defp merge_indicator_program(current_mapping, new_mapping) do
current_mapping |> Map.merge(new_mapping) |> remove_nil_values()
end
defp do_render(state, priority, indicators) do
with :ok <- check_priority(priority, state),
:ok <- check_indicator_programs(indicators, state) do
merged_indicators = merge_indicator_program(Map.get(state.active, priority), indicators)
new_active = Map.put(state.active, priority, merged_indicators)
new_state = %{state | active: new_active}
refresh_indicators(new_state)
new_timers = start_timer(state.timers, priority, merged_indicators)
{:ok, %{new_state | timers: new_timers}}
end
end
defp check_priority(priority, state) do
if priority in state.priorities do
:ok
else
{:error, %ArgumentError{message: "Invalid priority #{inspect(priority)}"}}
end
end
defp check_indicator_programs(indicators, state) do
names = Map.keys(indicators)
case Enum.find(names, fn name -> name not in state.indicator_names end) do
nil -> :ok
name -> {:error, %ArgumentError{message: "Invalid indicator #{inspect(name)}"}}
end
end
defp find_max_duration(indicators) when map_size(indicators) == 0, do: :infinity
defp find_max_duration(indicators) do
durations = for {_indicator, program} <- indicators, do: program.duration
Enum.max(durations)
end
defp start_timer(timers, priority, indicators) do
duration = find_max_duration(indicators)
if duration != :infinity do
ref = make_ref()
timer_ref = Process.send_after(self(), {:clear, priority, ref}, duration)
case Map.get(timers, priority) do
{old_timer_ref, _ref} ->
_ = Process.cancel_timer(old_timer_ref)
:ok
_ ->
:ok
end
Map.put(timers, priority, {timer_ref, ref})
else
timers
end
end
defp do_clear(state, priority) do
with :ok <- check_priority(priority, state) do
new_active = Map.delete(state.active, priority)
new_state = %{state | active: new_active}
refresh_indicators(new_state)
{:ok, new_state}
end
end
@spec summarize_programs(state()) :: %{indicator_name() => Program.t()}
defp summarize_programs(state) do
Enum.reduce(state.priorities, state.all_off, fn priority, acc ->
case Map.fetch(state.active, priority) do
{:ok, indicator_programs} -> Map.merge(acc, indicator_programs)
:error -> acc
end
end)
end
defp refresh_indicators(state) do
summarized = summarize_programs(state)
Enum.each(summarized, fn {indicator, program} ->
Glue.set_program!(state.glue[indicator], program, state.brightness)
end)
end
@impl GenServer
def handle_info({:clear, priority, ref}, state) do
case Map.get(state.timers, priority) do
{_timer_ref, ^ref} ->
new_timers = Map.delete(state.timers, priority)
new_active = Map.delete(state.active, priority)
new_state = %{state | active: new_active, timers: new_timers}
refresh_indicators(new_state)
{:noreply, new_state}
_ ->
# Old timeout message - ignore
{:noreply, state}
end
end
defp open_indicators(led_path, indicator_configs) do
for {name, config} <- indicator_configs, reduce: %{} do
acc -> Map.put(acc, name, Glue.open(led_path, config[:red], config[:green], config[:blue]))
end
end
end