diff --git a/.formatter.exs b/.formatter.exs index a895ce4..5b2bcb7 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,8 +1,7 @@ # Used by "mix format" [ - inputs: [ - "{mix,.formatter}.exs", - "{config,lib,test}/**/*.{ex,exs}", - "dialyzer.ignore.exs" + inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"], + locals_without_parens: [ + field: 3 ] ] diff --git a/lib/layout.ex b/lib/layout.ex new file mode 100644 index 0000000..cfc11b4 --- /dev/null +++ b/lib/layout.ex @@ -0,0 +1,53 @@ +defmodule Layout do + @moduledoc """ + Describes a keyboard layout. + """ + + alias __MODULE__.{Key, LED} + + # FIXME: what's wrong with this type? + # @type t :: %__MODULE__{ + # keys: [Key.t()], + # leds: [LED.t()], + # leds_by_keys: %{Key.id() => LED.t()}, + # keys_by_leds: %{LED.id() => Key.t()} + # } + @type t :: %__MODULE__{} + defstruct [:keys, :leds, :leds_by_keys, :keys_by_leds] + + @spec new(keys :: [Key.t()], leds :: [LED.t()]) :: t + def new(keys, leds \\ []) do + leds_map = Map.new(leds, &{&1.id, &1}) + + leds_by_keys = + keys + |> Enum.filter(& &1.led) + |> Map.new(&{&1.id, Map.fetch!(leds_map, &1.led)}) + + keys_by_leds = + keys + |> Enum.filter(& &1.led) + |> Map.new(&{&1.led, &1}) + + %__MODULE__{ + keys: keys, + leds: leds, + leds_by_keys: leds_by_keys, + keys_by_leds: keys_by_leds + } + end + + @spec keys(layout :: t) :: [Key.t()] + def keys(layout), do: layout.keys + + @spec leds(layout :: t) :: [LED.t()] + def leds(layout), do: layout.leds + + @spec led_for_key(layout :: t, Key.id()) :: LED.t() | nil + def led_for_key(%__MODULE__{} = layout, key_id) when is_atom(key_id), + do: Map.get(layout.leds_by_keys, key_id) + + @spec key_for_led(layout :: t, LED.id()) :: Key.t() | nil + def key_for_led(%__MODULE__{} = layout, led_id) when is_atom(led_id), + do: Map.get(layout.keys_by_leds, led_id) +end diff --git a/lib/layout/key.ex b/lib/layout/key.ex new file mode 100644 index 0000000..bea7d0e --- /dev/null +++ b/lib/layout/key.ex @@ -0,0 +1,28 @@ +defmodule Layout.Key do + @moduledoc """ + Describes a physical key and its location. + """ + + @type id :: atom + + @type t :: %__MODULE__{ + id: id, + x: float, + y: float, + width: float, + height: float, + led: atom + } + defstruct [:id, :x, :y, :width, :height, :led] + + def new(id, x, y, opts \\ []) do + %__MODULE__{ + id: id, + x: x, + y: y, + width: Keyword.get(opts, :width, 1), + height: Keyword.get(opts, :height, 1), + led: Keyword.get(opts, :led) + } + end +end diff --git a/lib/layout/led.ex b/lib/layout/led.ex new file mode 100644 index 0000000..dad6cf4 --- /dev/null +++ b/lib/layout/led.ex @@ -0,0 +1,22 @@ +defmodule Layout.LED do + @moduledoc """ + Describes a physical LED location. + """ + + @type id :: atom + + @type t :: %__MODULE__{ + id: id, + x: float, + y: float + } + defstruct [:id, :x, :y] + + def new(id, x, y) do + %__MODULE__{ + id: id, + x: x, + y: y + } + end +end diff --git a/lib/rgb_matrix.ex b/lib/rgb_matrix.ex new file mode 100644 index 0000000..53ebaf7 --- /dev/null +++ b/lib/rgb_matrix.ex @@ -0,0 +1,10 @@ +defmodule RGBMatrix do + @type any_color_model :: + Chameleon.Color.RGB.t() + | Chameleon.Color.CMYK.t() + | Chameleon.Color.Hex.t() + | Chameleon.Color.HSL.t() + | Chameleon.Color.HSV.t() + | Chameleon.Color.Keyword.t() + | Chameleon.Color.Pantone.t() +end diff --git a/lib/rgb_matrix/animation.ex b/lib/rgb_matrix/animation.ex index cdcedf7..2d06f3d 100644 --- a/lib/rgb_matrix/animation.ex +++ b/lib/rgb_matrix/animation.ex @@ -1,109 +1,96 @@ defmodule RGBMatrix.Animation do @moduledoc """ - Provides a data structure and functions to define an RGBMatrix animation. + Provides the behaviour and interface for working with animations. + """ - There are currently two distinct ways to define an animation. + alias Layout.LED + alias RGBMatrix.Animation.Config - You may define an animation with a predefined `:frames` field. Each frame will advance every `:delay_ms` milliseconds. - These animations should use the `RGBMatrix.Animation.Static` `:type`. See the moduledocs of that module for - examples. + @type animation_state :: any - Alternatively, you may have a more dynamic animation which generates frames based on the current `:tick` of the - animation. See `RGBMatrix.Animation.{CycleAll, CycleLeftToRight, Pinwheel}` for examples. - """ + @type t :: %__MODULE__{ + type: type, + config: Config.t(), + state: any + } + defstruct [:type, :config, :state] - alias __MODULE__ - alias RGBMatrix.Frame + @callback new(leds :: [LED.t()], config :: Config.t()) :: {render_in, animation_state} + @callback render(state :: animation_state, config :: Config.t()) :: + {render_in, [RGBMatrix.any_color_model()], animation_state} + @callback interact(state :: animation_state, config :: Config.t(), led :: LED.t()) :: + {render_in, animation_state} defmacro __using__(_) do quote do - alias RGBMatrix.Animation - - @behaviour Animation + @behaviour RGBMatrix.Animation end end - @callback next_frame(animation :: Animation.t()) :: Frame.t() + @type render_in :: non_neg_integer() | :never | :ignore - @type t :: %__MODULE__{ - type: animation_type, - tick: non_neg_integer, - speed: non_neg_integer, - loop: non_neg_integer | :infinite, - delay_ms: non_neg_integer, - frames: list(Frame.t()), - next_frame: Frame.t() | nil - } - defstruct [:type, :tick, :speed, :delay_ms, :loop, :next_frame, :frames] - - @type animation_type :: + @type type :: __MODULE__.CycleAll - | __MODULE__.CycleLeftToRight + | __MODULE__.HueWave | __MODULE__.Pinwheel - | __MODULE__.Static + | __MODULE__.RandomSolid + | __MODULE__.RandomKeypresses + | __MODULE__.SolidColor + | __MODULE__.Breathing + | __MODULE__.SolidReactive @doc """ Returns a list of the available types of animations. """ - @spec types :: list(animation_type) + @spec types :: [type] def types do [ __MODULE__.CycleAll, - __MODULE__.CycleLeftToRight, - __MODULE__.Pinwheel + __MODULE__.HueWave, + __MODULE__.Pinwheel, + __MODULE__.RandomSolid, + __MODULE__.RandomKeypresses, + __MODULE__.SolidColor, + __MODULE__.Breathing, + __MODULE__.SolidReactive ] end - @type animation_opt :: - {:type, animation_type} - | {:frames, list} - | {:tick, non_neg_integer} - | {:speed, non_neg_integer} - | {:delay_ms, non_neg_integer} - | {:loop, non_neg_integer | :infinite} - - @spec new(opts :: list(animation_opt)) :: Animation.t() - def new(opts) do - animation_type = Keyword.fetch!(opts, :type) - frames = Keyword.get(opts, :frames, []) + @doc """ + Returns an animation's initial state. + """ + @spec new(animation_type :: type, leds :: [LED.t()]) :: {render_in, t} + def new(animation_type, leds) do + config_module = Module.concat([animation_type, Config]) + animation_config = config_module.new() + {render_in, animation_state} = animation_type.new(leds, animation_config) - %Animation{ + animation = %__MODULE__{ type: animation_type, - tick: opts[:tick] || 0, - speed: opts[:speed] || 100, - delay_ms: opts[:delay_ms] || 17, - loop: opts[:loop] || :infinite, - frames: frames, - next_frame: List.first(frames) + config: animation_config, + state: animation_state } - end - @doc """ - Updates the state of an animation with the next tick of animation. - """ - @spec next_frame(animation :: Animation.t()) :: Animation.t() - def next_frame(animation) do - next_frame = animation.type.next_frame(animation) - %Animation{animation | next_frame: next_frame, tick: animation.tick + 1} + {render_in, animation} end @doc """ - Returns the frame count of a given animation, - - Note: this function returns :infinite for dynamic animations. + Returns the next state of an animation based on its current state. """ - @spec frame_count(animation :: Animation.t()) :: non_neg_integer | :infinite - def frame_count(%{loop: :infinite}), do: :infinite + @spec render(animation :: t) :: {render_in, [RGBMatrix.any_color_model()], t} + def render(animation) do + {render_in, colors, animation_state} = + animation.type.render(animation.state, animation.config) - def frame_count(animation), do: length(animation.frames) * animation.loop + {render_in, colors, %{animation | state: animation_state}} + end @doc """ - Returns the expected duration of a given animation. - - Note: this function returns :infinite for dynamic animations. + Sends an interaction event to an animation. """ - @spec duration(animation :: Animation.t()) :: non_neg_integer | :infinite - def duration(%{loop: :infinite}), do: :infinite - - def duration(animation), do: frame_count(animation) * animation.delay_ms + @spec interact(animation :: t, led :: LED.t()) :: {render_in, t} + def interact(animation, led) do + {render_in, animation_state} = animation.type.interact(animation.state, animation.config, led) + {render_in, %{animation | state: animation_state}} + end end diff --git a/lib/rgb_matrix/animation/breathing.ex b/lib/rgb_matrix/animation/breathing.ex new file mode 100644 index 0000000..80ac56e --- /dev/null +++ b/lib/rgb_matrix/animation/breathing.ex @@ -0,0 +1,47 @@ +defmodule RGBMatrix.Animation.Breathing do + @moduledoc """ + Single hue brightness cycling. + """ + + alias Chameleon.HSV + alias RGBMatrix.Animation + + use Animation + + defmodule Config do + @moduledoc false + use RGBMatrix.Animation.Config + end + + defmodule State do + @moduledoc false + defstruct [:color, :tick, :speed, :led_ids] + end + + @delay_ms 17 + + @impl true + def new(leds, _config) do + # TODO: configurable base color + color = HSV.new(40, 100, 100) + led_ids = Enum.map(leds, & &1.id) + {0, %State{color: color, tick: 0, speed: 100, led_ids: led_ids}} + end + + @impl true + def render(state, _config) do + %{color: base_color, tick: tick, speed: speed, led_ids: led_ids} = state + + value = trunc(abs(:math.sin(tick * speed / 5_000)) * base_color.v) + color = HSV.new(base_color.h, base_color.s, value) + + colors = Enum.map(led_ids, fn id -> {id, color} end) + + {@delay_ms, colors, %{state | tick: tick + 1}} + end + + @impl true + def interact(state, _config, _led) do + {:ignore, state} + end +end diff --git a/lib/rgb_matrix/animation/config.ex b/lib/rgb_matrix/animation/config.ex new file mode 100644 index 0000000..a74c3bd --- /dev/null +++ b/lib/rgb_matrix/animation/config.ex @@ -0,0 +1,92 @@ +defmodule RGBMatrix.Animation.Config do + @moduledoc """ + Provides a behaviour and macros for defining animation configurations. + """ + + # An animation config is a struct, but we don't know ahead of time all the + # concrete types of struct it might be. (e.g.: + # RGBMatrix.Animation.HueWave.Config.t) + @type t :: struct + + @callback schema() :: keyword(any) + @callback new(%{optional(atom) => any}) :: t + @callback update(t, %{optional(atom) => any}) :: t + + @types %{ + integer: RGBMatrix.Animation.Config.FieldType.Integer, + option: RGBMatrix.Animation.Config.FieldType.Option + } + + defmacro __using__(_) do + quote do + import RGBMatrix.Animation.Config + + Module.register_attribute(__MODULE__, :fields, + accumulate: true, + persist: false + ) + + @before_compile RGBMatrix.Animation.Config + end + end + + defmacro field(name, type, opts \\ []) do + type = Map.fetch!(@types, type) + type_schema = Macro.escape(struct!(type, opts)) + + quote do + @fields {unquote(name), unquote(type_schema)} + end + end + + defmacro __before_compile__(env) do + schema = Module.get_attribute(env.module, :fields) + keys = Keyword.keys(schema) + schema = Macro.escape(schema) + + quote do + @behaviour RGBMatrix.Animation.Config + + @enforce_keys unquote(keys) + defstruct unquote(keys) + + @impl RGBMatrix.Animation.Config + def schema do + unquote(schema) + end + + @impl RGBMatrix.Animation.Config + def new(params \\ %{}) do + schema() + |> Map.new(fn {key, %mod{} = type} -> + value = Map.get(params, key, type.default) + + case mod.validate(type, value) do + :ok -> {key, value} + :error -> value_error!(value, key) + end + end) + |> (&struct!(__MODULE__, &1)).() + end + + @impl RGBMatrix.Animation.Config + def update(config, params) do + schema = schema() + + Enum.reduce(params, config, fn {key, value}, config -> + %mod{} = type = Keyword.fetch!(schema, key) + if mod.validate(type, value) == :error, do: value_error!(value, key) + + Map.put(config, key, value) + end) + end + + defp value_error!(value, key) do + message = + "#{__MODULE__}: value `#{inspect(value)}` is invalid for config option `#{key}`." + + raise ArgumentError, message: message + end + end + end +end diff --git a/lib/rgb_matrix/animation/config/field_type.ex b/lib/rgb_matrix/animation/config/field_type.ex new file mode 100644 index 0000000..c3ddc5f --- /dev/null +++ b/lib/rgb_matrix/animation/config/field_type.ex @@ -0,0 +1,16 @@ +defmodule RGBMatrix.Animation.Config.FieldType do + @moduledoc """ + Provides a behaviour for defining animation configuration field types. + """ + + @type t :: __MODULE__.Integer.t() | __MODULE__.Option.t() + + @callback validate(t, any) :: :ok | :error + @callback cast(t, any) :: {:ok, any} | :error + + defmacro __using__(_) do + quote do + @behaviour RGBMatrix.Animation.Config.FieldType + end + end +end diff --git a/lib/rgb_matrix/animation/config/field_type/integer.ex b/lib/rgb_matrix/animation/config/field_type/integer.ex new file mode 100644 index 0000000..c6ef7cd --- /dev/null +++ b/lib/rgb_matrix/animation/config/field_type/integer.ex @@ -0,0 +1,61 @@ +defmodule RGBMatrix.Animation.Config.FieldType.Integer do + @moduledoc """ + An integer field type for use in animation configuration. + + Supports defining a minimum and a maximum, as well as a step value. + """ + + use RGBMatrix.Animation.Config.FieldType + + @type t :: %__MODULE__{ + default: integer, + min: integer, + max: integer + } + @enforce_keys [:default, :min, :max] + defstruct [:default, :min, :max, step: 1] + + import RGBMatrix.Utils, only: [mod: 2] + + @impl true + @spec validate(field_type :: t, value :: integer) :: :ok | :error + def validate(field_type, value) do + if value >= field_type.min && + value <= field_type.max && + mod(value, field_type.step) == 0 do + :ok + else + :error + end + end + + @impl true + @spec cast(field_type :: t, value :: any) :: {:ok, integer} | :error + def cast(field_type, value) do + with {:ok, casted_value} <- do_cast(value), + :ok <- validate(field_type, casted_value) do + {:ok, casted_value} + else + :error -> :error + end + end + + defp do_cast(value) when is_integer(value) do + {:ok, value} + end + + defp do_cast(value) when is_float(value) do + {:ok, trunc(value)} + end + + defp do_cast(value) when is_binary(value) do + case Integer.parse(value) do + {parsed_value, _remaining_string} -> {:ok, parsed_value} + :error -> :error + end + end + + defp do_cast(_) do + :error + end +end diff --git a/lib/rgb_matrix/animation/config/field_type/option.ex b/lib/rgb_matrix/animation/config/field_type/option.ex new file mode 100644 index 0000000..2bc06b2 --- /dev/null +++ b/lib/rgb_matrix/animation/config/field_type/option.ex @@ -0,0 +1,49 @@ +defmodule RGBMatrix.Animation.Config.FieldType.Option do + @moduledoc """ + An option field type for use in animation configuration. + + Supports defining a list of pre-defined options as atoms. + """ + + use RGBMatrix.Animation.Config.FieldType + + @type t :: %__MODULE__{ + default: atom, + options: [atom] + } + @enforce_keys [:default, :options] + defstruct [:default, :options] + + @impl true + @spec validate(field_type :: t, value :: atom) :: :ok | :error + def validate(option, value) do + if value in option.options do + :ok + else + :error + end + end + + @impl true + @spec cast(field_type :: t, value :: any) :: {:ok, atom} | :error + def cast(field_type, value) do + with {:ok, casted_value} <- do_cast(value), + :ok <- validate(field_type, casted_value) do + {:ok, casted_value} + else + :error -> :error + end + end + + defp do_cast(binary_value) when is_binary(binary_value) do + try do + {:ok, String.to_existing_atom(binary_value)} + rescue + ArgumentError -> :error + end + end + + defp do_cast(value) when is_atom(value), do: {:ok, value} + + defp do_cast(_value), do: :error +end diff --git a/lib/rgb_matrix/animation/cycle_all.ex b/lib/rgb_matrix/animation/cycle_all.ex index 11d3d97..55942f4 100644 --- a/lib/rgb_matrix/animation/cycle_all.ex +++ b/lib/rgb_matrix/animation/cycle_all.ex @@ -1,28 +1,48 @@ defmodule RGBMatrix.Animation.CycleAll do @moduledoc """ - Cycles hue of all keys. + Cycles the hue of all LEDs at the same time. """ alias Chameleon.HSV + alias RGBMatrix.Animation - alias RGBMatrix.{Animation, Frame} + use Animation import RGBMatrix.Utils, only: [mod: 2] - use Animation + defmodule Config do + @moduledoc false + use RGBMatrix.Animation.Config + end - @impl Animation - def next_frame(animation) do - %Animation{tick: tick, speed: speed} = animation - time = div(tick * speed, 100) + defmodule State do + @moduledoc false + defstruct [:tick, :speed, :led_ids] + end + + @delay_ms 17 + @impl true + def new(leds, _config) do + led_ids = Enum.map(leds, & &1.id) + {0, %State{tick: 0, speed: 100, led_ids: led_ids}} + end + + @impl true + def render(state, _config) do + %{tick: tick, speed: speed, led_ids: led_ids} = state + + time = div(tick * speed, 100) hue = mod(time, 360) color = HSV.new(hue, 100, 100) - # FIXME: no reaching into Xebow namespace - pixels = Xebow.Utils.pixels() - pixel_colors = Enum.map(pixels, fn {_x, _y} -> color end) + colors = Enum.map(led_ids, fn id -> {id, color} end) + + {@delay_ms, colors, %{state | tick: tick + 1}} + end - Frame.new(pixels, pixel_colors) + @impl true + def interact(state, _config, _led) do + {:ignore, state} end end diff --git a/lib/rgb_matrix/animation/cycle_left_to_right.ex b/lib/rgb_matrix/animation/cycle_left_to_right.ex deleted file mode 100644 index 88d91f2..0000000 --- a/lib/rgb_matrix/animation/cycle_left_to_right.ex +++ /dev/null @@ -1,30 +0,0 @@ -defmodule RGBMatrix.Animation.CycleLeftToRight do - @moduledoc """ - Cycles hue left to right. - """ - - alias Chameleon.HSV - - alias RGBMatrix.{Animation, Frame} - - import RGBMatrix.Utils, only: [mod: 2] - - use Animation - - @impl Animation - def next_frame(animation) do - %Animation{tick: tick, speed: speed} = animation - time = div(tick * speed, 100) - - # FIXME: no reaching into Xebow namespace - pixels = Xebow.Utils.pixels() - - pixel_colors = - for {x, _y} <- pixels do - hue = mod(x * 10 - time, 360) - HSV.new(hue, 100, 100) - end - - Frame.new(pixels, pixel_colors) - end -end diff --git a/lib/rgb_matrix/animation/hue_wave.ex b/lib/rgb_matrix/animation/hue_wave.ex new file mode 100644 index 0000000..13c483b --- /dev/null +++ b/lib/rgb_matrix/animation/hue_wave.ex @@ -0,0 +1,104 @@ +defmodule RGBMatrix.Animation.HueWave do + @moduledoc """ + Creates a wave of shifting hue that moves across the matrix. + """ + + alias Chameleon.HSV + alias Layout.LED + alias RGBMatrix.Animation + + use Animation + + import RGBMatrix.Utils, only: [mod: 2] + + defmodule Config do + @moduledoc false + use RGBMatrix.Animation.Config + + @doc name: "Speed", + description: """ + Controls the speed at which the wave moves across the matrix. + """ + field :speed, :integer, default: 4, min: 0, max: 32 + + @doc name: "Width", + description: """ + The rate of change of the wave, higher values means it's more spread out. + """ + field :width, :integer, default: 20, min: 10, max: 100, step: 10 + + @doc name: "Direction", + description: """ + The direction the wave travels across the matrix. + """ + field :direction, :option, + default: :right, + options: [ + :right, + :left, + :up, + :down + ] + end + + defmodule State do + @moduledoc false + defstruct [:tick, :leds, :steps] + end + + @delay_ms 17 + + @impl true + def new(leds, config) do + steps = 360 / config.width + {0, %State{tick: 0, leds: leds, steps: steps}} + end + + @impl true + def render(state, config) do + %{tick: tick, leds: leds, steps: _steps} = state + %{speed: speed, direction: direction} = config + + # TODO: fixme + steps = 360 / config.width + + time = div(tick * speed, 5) + + colors = render_colors(leds, steps, time, direction) + + {@delay_ms, colors, %{state | tick: tick + 1}} + end + + defp render_colors(leds, steps, time, :right) do + for %LED{id: id, x: x} <- leds do + hue = mod(trunc(x * steps) - time, 360) + {id, HSV.new(hue, 100, 100)} + end + end + + defp render_colors(leds, steps, time, :left) do + for %LED{id: id, x: x} <- leds do + hue = mod(trunc(x * steps) + time, 360) + {id, HSV.new(hue, 100, 100)} + end + end + + defp render_colors(leds, steps, time, :up) do + for %LED{id: id, y: y} <- leds do + hue = mod(trunc(y * steps) + time, 360) + {id, HSV.new(hue, 100, 100)} + end + end + + defp render_colors(leds, steps, time, :down) do + for %LED{id: id, y: y} <- leds do + hue = mod(trunc(y * steps) - time, 360) + {id, HSV.new(hue, 100, 100)} + end + end + + @impl true + def interact(state, _config, _led) do + {:ignore, state} + end +end diff --git a/lib/rgb_matrix/animation/pinwheel.ex b/lib/rgb_matrix/animation/pinwheel.ex index 4149c0e..c5813bb 100644 --- a/lib/rgb_matrix/animation/pinwheel.ex +++ b/lib/rgb_matrix/animation/pinwheel.ex @@ -4,37 +4,57 @@ defmodule RGBMatrix.Animation.Pinwheel do """ alias Chameleon.HSV + alias Layout.LED + alias RGBMatrix.Animation - alias RGBMatrix.{Animation, Frame} + use Animation import RGBMatrix.Utils, only: [mod: 2] - use Animation + defmodule Config do + @moduledoc false + use RGBMatrix.Animation.Config + end - @center %{ - x: 1, - y: 1.5 - } + defmodule State do + @moduledoc false + defstruct [:tick, :speed, :leds, :center] + end - @impl Animation - def next_frame(animation) do - %Animation{tick: tick, speed: speed} = animation - time = div(tick * speed, 100) + @delay_ms 17 + + @impl true + def new(leds, _config) do + {0, %State{tick: 0, speed: 100, leds: leds, center: determine_center(leds)}} + end - # FIXME: no reaching into Xebow namespace - pixels = Xebow.Utils.pixels() + defp determine_center(leds) do + {%{x: min_x}, %{x: max_x}} = Enum.min_max_by(leds, & &1.x) + {%{y: min_y}, %{y: max_y}} = Enum.min_max_by(leds, & &1.y) - pixel_colors = - for {x, y} <- pixels do - dx = x - @center.x - dy = y - @center.y + %{ + x: (max_x - min_x) / 2 + min_x, + y: (max_y - min_y) / 2 + min_y + } + end + + @impl true + def render(state, _config) do + %{tick: tick, speed: speed, leds: leds, center: center} = state + + time = div(tick * speed, 100) + + colors = + for %LED{id: id, x: x, y: y} <- leds do + dx = x - center.x + dy = y - center.y hue = mod(atan2_8(dy, dx) + time, 360) - HSV.new(hue, 100, 100) + {id, HSV.new(hue, 100, 100)} end - Frame.new(pixels, pixel_colors) + {@delay_ms, colors, %{state | tick: tick + 1}} end defp atan2_8(x, y) do @@ -42,4 +62,9 @@ defmodule RGBMatrix.Animation.Pinwheel do trunc((atan + :math.pi()) * 359 / (2 * :math.pi())) end + + @impl true + def interact(state, _config, %LED{x: x, y: y}) do + {:ignore, %{state | center: %{x: x, y: y}}} + end end diff --git a/lib/rgb_matrix/animation/random_keypresses.ex b/lib/rgb_matrix/animation/random_keypresses.ex new file mode 100644 index 0000000..380e640 --- /dev/null +++ b/lib/rgb_matrix/animation/random_keypresses.ex @@ -0,0 +1,54 @@ +defmodule RGBMatrix.Animation.RandomKeypresses do + @moduledoc """ + Changes every key pressed to a random color. + """ + + alias Chameleon.HSV + alias RGBMatrix.Animation + + use Animation + + defmodule Config do + @moduledoc false + use RGBMatrix.Animation.Config + end + + defmodule State do + @moduledoc false + defstruct [:led_ids, :dirty] + end + + @impl true + def new(leds, _config) do + led_ids = Enum.map(leds, & &1.id) + + {0, + %State{ + led_ids: led_ids, + # NOTE: as to not conflict with possible led ID of `:all` + dirty: {:all} + }} + end + + @impl true + def render(state, _config) do + %{led_ids: led_ids, dirty: dirty} = state + + colors = + case dirty do + {:all} -> Enum.map(led_ids, fn id -> {id, random_color()} end) + id -> [{id, random_color()}] + end + + {:never, colors, state} + end + + defp random_color do + HSV.new((:rand.uniform() * 360) |> trunc(), 100, 100) + end + + @impl true + def interact(state, _config, led) do + {0, %{state | dirty: led.id}} + end +end diff --git a/lib/rgb_matrix/animation/random_solid.ex b/lib/rgb_matrix/animation/random_solid.ex new file mode 100644 index 0000000..a604489 --- /dev/null +++ b/lib/rgb_matrix/animation/random_solid.ex @@ -0,0 +1,45 @@ +defmodule RGBMatrix.Animation.RandomSolid do + @moduledoc """ + A random solid color fills the entire matrix and changes every key-press. + """ + + alias Chameleon.HSV + alias RGBMatrix.Animation + + use Animation + + defmodule Config do + @moduledoc false + use RGBMatrix.Animation.Config + end + + defmodule State do + @moduledoc false + defstruct [:led_ids] + end + + @impl true + def new(leds, _config) do + {0, %State{led_ids: Enum.map(leds, & &1.id)}} + end + + @impl true + def render(state, _config) do + %{led_ids: led_ids} = state + + color = random_color() + + colors = Enum.map(led_ids, fn id -> {id, color} end) + + {:never, colors, state} + end + + defp random_color do + HSV.new((:rand.uniform() * 360) |> trunc(), 100, 100) + end + + @impl true + def interact(state, _config, _led) do + {0, state} + end +end diff --git a/lib/rgb_matrix/animation/solid_color.ex b/lib/rgb_matrix/animation/solid_color.ex new file mode 100644 index 0000000..09c34bf --- /dev/null +++ b/lib/rgb_matrix/animation/solid_color.ex @@ -0,0 +1,41 @@ +defmodule RGBMatrix.Animation.SolidColor do + @moduledoc """ + All LEDs are a solid color. + """ + + alias Chameleon.HSV + alias RGBMatrix.Animation + + use Animation + + defmodule Config do + @moduledoc false + use RGBMatrix.Animation.Config + end + + defmodule State do + @moduledoc false + defstruct [:color, :led_ids] + end + + @impl true + def new(leds, _config) do + # TODO: configurable base color + color = HSV.new(120, 100, 100) + {0, %State{color: color, led_ids: Enum.map(leds, & &1.id)}} + end + + @impl true + def render(state, _config) do + %{color: color, led_ids: led_ids} = state + + colors = Enum.map(led_ids, fn id -> {id, color} end) + + {:never, colors, state} + end + + @impl true + def interact(state, _config, _led) do + {:ignore, state} + end +end diff --git a/lib/rgb_matrix/animation/solid_reactive.ex b/lib/rgb_matrix/animation/solid_reactive.ex new file mode 100644 index 0000000..bba9f0f --- /dev/null +++ b/lib/rgb_matrix/animation/solid_reactive.ex @@ -0,0 +1,115 @@ +defmodule RGBMatrix.Animation.SolidReactive do + @moduledoc """ + Static single hue, pulses keys hit to shifted hue then fades to current hue. + """ + + alias Chameleon.HSV + alias RGBMatrix.Animation + + use Animation + + import RGBMatrix.Utils, only: [mod: 2] + + defmodule Config do + @moduledoc false + use RGBMatrix.Animation.Config + + @doc name: "Speed", + description: """ + The speed at which the hue shifts back to base. + """ + field :speed, :integer, default: 4, min: 0, max: 32 + + @doc name: "Distance", + description: """ + The distance that the hue shifts on key-press. + """ + field :distance, :integer, default: 180, min: 0, max: 360, step: 10 + + @doc name: "Direction", + description: """ + The direction (through the color wheel) that the hue shifts on key-press. + """ + field :direction, :option, + default: :random, + options: [ + :random, + :negative, + :positive + ] + end + + defmodule State do + @moduledoc false + defstruct [:first_render, :paused, :tick, :color, :leds, :hits] + end + + @delay_ms 17 + + @impl true + def new(leds, _config) do + # TODO: configurable base color + color = HSV.new(190, 100, 100) + {0, %State{first_render: true, paused: false, tick: 0, color: color, leds: leds, hits: %{}}} + end + + @impl true + def render(%{first_render: true} = state, _config) do + %{color: color, leds: leds} = state + + colors = Enum.map(leds, &{&1.id, color}) + + {colors, :never, %{state | first_render: false, paused: true}} + end + + def render(%{paused: true} = state, _config), + do: {[], :never, state} + + def render(state, config) do + %{tick: tick, color: color, leds: leds, hits: hits} = state + %{speed: _speed, distance: distance} = config + + {colors, hits} = + Enum.map_reduce(leds, hits, fn led, hits -> + case hits do + %{^led => {hit_tick, direction_modifier}} -> + # TODO: take speed into account + if tick - hit_tick >= distance do + {{led.id, color}, Map.delete(hits, led)} + else + hue = mod(color.h + (tick - hit_tick - distance) * direction_modifier, 360) + {{led.id, HSV.new(hue, color.s, color.v)}, hits} + end + + _else -> + {{led.id, color}, hits} + end + end) + + # FIXME: leaves color 1 away from base + # TODO: we can optimize this by rewriting the above instead of filtering here: + colors = + Enum.filter(colors, fn {_id, this_color} -> + this_color != color + end) + + {@delay_ms, colors, %{state | tick: tick + 1, hits: hits, paused: hits == %{}}} + end + + @impl true + def interact(state, config, led) do + direction = direction_modifier(config.direction) + + render_in = + case state.paused do + true -> 0 + false -> :ignore + end + + {render_in, %{state | paused: false, hits: Map.put(state.hits, led, {state.tick, direction})}} + end + + defp direction_modifier(:random), do: Enum.random([-1, 1]) + defp direction_modifier(:negative), do: -1 + defp direction_modifier(:positive), do: 1 +end diff --git a/lib/rgb_matrix/animation/static.ex b/lib/rgb_matrix/animation/static.ex deleted file mode 100644 index d28ae5b..0000000 --- a/lib/rgb_matrix/animation/static.ex +++ /dev/null @@ -1,114 +0,0 @@ -defmodule RGBMatrix.Animation.Static do - @moduledoc """ - Pre-defined animations that run as a one-shot or on a loop. - - The `RGBMatrix.Animation.Static` animation type is used to define animations with a pre-defined set of frames. These - animations can be played continiously or on a loop. This behavior is controlled by the `:loop` field on the animation. - A value of `:infinite` means to play the animation continuously, while a value greater than 0 means to loop the animation - `:loop` times. - - Note that the playback speed of these animations is controlled by the `:delay_ms` field in the animation struct. The - `:speed` field not used when rendering these animations. - - ## Examples - - ### Play a random color on each pixel for 10 frames, continuously. - ``` - gen_map = fn -> - Enum.into(Xebow.Utils.pixels(), %{}, fn pixel -> - {pixel, Chameleon.HSV.new(:random.uniform(360), 100, 100)} - end) - end - - generator = fn -> struct!(RGBMatrix.Frame, pixel_map: gen_map.()) end - frames = Stream.repeatedly(generator) |> Enum.take(10) - - animation = - %RGBMatrix.Animation{ - delay_ms: 100, - frames: frames, - tick: 0, - loop: :infinite, - type: RGBMatrix.Animation.Static - } - - Xebow.LEDs.play_animation(animation) - ``` - - ### Play a pre-defined animation, three times - ``` - frames = [ - %RGBMatrix.Frame{ - pixel_map: %{ - {0, 0} => %Chameleon.HSV{h: 100, s: 100, v: 100}, - {0, 1} => %Chameleon.HSV{h: 100, s: 100, v: 100}, - {0, 2} => %Chameleon.HSV{h: 100, s: 100, v: 100}, - {0, 3} => %Chameleon.HSV{h: 100, s: 100, v: 100}, - {1, 0} => %Chameleon.HSV{h: 0, s: 0, v: 0}, - {1, 1} => %Chameleon.HSV{h: 0, s: 0, v: 0}, - {1, 2} => %Chameleon.HSV{h: 0, s: 0, v: 0}, - {1, 3} => %Chameleon.HSV{h: 0, s: 0, v: 0}, - {2, 0} => %Chameleon.HSV{h: 100, s: 100, v: 100}, - {2, 1} => %Chameleon.HSV{h: 100, s: 100, v: 100}, - {2, 2} => %Chameleon.HSV{h: 100, s: 100, v: 100}, - {2, 3} => %Chameleon.HSV{h: 100, s: 100, v: 100} - } - }, - %RGBMatrix.Frame{ - pixel_map: %{ - {0, 0} => %Chameleon.HSV{h: 0, s: 0, v: 0}, - {0, 1} => %Chameleon.HSV{h: 0, s: 0, v: 0}, - {0, 2} => %Chameleon.HSV{h: 0, s: 0, v: 0}, - {0, 3} => %Chameleon.HSV{h: 0, s: 0, v: 0}, - {1, 0} => %Chameleon.HSV{h: 100, s: 100, v: 100}, - {1, 1} => %Chameleon.HSV{h: 100, s: 100, v: 100}, - {1, 2} => %Chameleon.HSV{h: 100, s: 100, v: 100}, - {1, 3} => %Chameleon.HSV{h: 100, s: 100, v: 100}, - {2, 0} => %Chameleon.HSV{h: 0, s: 0, v: 0}, - {2, 1} => %Chameleon.HSV{h: 0, s: 0, v: 0}, - {2, 2} => %Chameleon.HSV{h: 0, s: 0, v: 0}, - {2, 3} => %Chameleon.HSV{h: 0, s: 0, v: 0} - } - } - ] - - animation = %RGBMatrix.Animation{ - delay_ms: 200, - frames: frames, - tick: 0, - loop: 3, - type: RGBMatrix.Animation.Static - } - - Xebow.LEDs.play_animation(animation) - ``` - """ - - alias RGBMatrix.Animation - - import RGBMatrix.Utils, only: [mod: 2] - - use Animation - - @impl Animation - def next_frame(%{loop: :infinite} = animation) do - %Animation{frames: frames, tick: tick} = animation - - index = mod(tick, length(frames)) - Enum.at(frames, index) - end - - @impl Animation - def next_frame(animation) do - %Animation{frames: frames, tick: tick, loop: loop} = animation - - all_frames = all_frames(frames, loop) - index = mod(tick, length(all_frames)) - Enum.at(all_frames, index) - end - - defp all_frames(frames, loop) do - List.duplicate(frames, loop) - |> List.flatten() - end -end diff --git a/lib/rgb_matrix/engine.ex b/lib/rgb_matrix/engine.ex index d1c27e7..2566546 100644 --- a/lib/rgb_matrix/engine.ex +++ b/lib/rgb_matrix/engine.ex @@ -1,16 +1,17 @@ defmodule RGBMatrix.Engine do @moduledoc """ - Renders [`Animation`](`RGBMatrix.Animation`)s and outputs - [`Frame`](`RGBMatrix.Frame`)s to be displayed. + Renders [`Animation`](`RGBMatrix.Animation`)s and outputs colors to be + displayed by [`Paintable`](`RGBMatrix.Paintable`)s. """ use GenServer + alias Layout.LED alias RGBMatrix.Animation defmodule State do @moduledoc false - defstruct [:animation, :paintables] + defstruct [:leds, :animation, :paintables, :last_frame, :timer] end # Client @@ -18,37 +19,31 @@ defmodule RGBMatrix.Engine do @doc """ Start the engine. - This module registers its process globally and is expected to be started by - a supervisor. + This module registers its process globally and is expected to be started by a + supervisor. This function accepts the following arguments as a tuple: - - `initial_animation` - The animation that plays when the engine starts. - - `paintables` - A list of modules to output `RGBMatrix.Frame` to that implement - the `RGBMatrix.Paintable` behavior. If you want to register your paintables - dynamically, set this to an empty list `[]`. + - `leds` - The list of LEDs to be painted on. + - `initial_animation` - The Animation type to initialize and play when the + engine starts. + - `paintables` - A list of modules to output colors to that implement the + `RGBMatrix.Paintable` behavior. If you want to register your paintables + dynamically, set this to an empty list `[]`. """ - @spec start_link({initial_animation :: Animation.t(), paintables :: list(module)}) :: + @spec start_link( + {leds :: [LED.t()], initial_animation_type :: Animation.type(), paintables :: [module]} + ) :: GenServer.on_start() - def start_link({initial_animation, paintables}) do - GenServer.start_link(__MODULE__, {initial_animation, paintables}, name: __MODULE__) + def start_link({leds, initial_animation_type, paintables}) do + GenServer.start_link(__MODULE__, {leds, initial_animation_type, paintables}, name: __MODULE__) end @doc """ - Play the given animation. - - Note that the animation can be played synchronously by passing `:false` for the `:async` option. However, only - looping (animations with `:loop` >= 1) animations may be played this way. This is to ensure that the caller is not - blocked forever. + Sets the given animation as the currently active animation. """ - @spec play_animation(animation :: Animation.t(), opts :: keyword()) :: :ok - def play_animation(animation, opts \\ []) do - async? = Keyword.get(opts, :async, true) - - if async? do - GenServer.cast(__MODULE__, {:play_animation, animation}) - else - GenServer.call(__MODULE__, {:play_animation, animation}) - end + @spec set_animation(animation_type :: Animation.type()) :: :ok + def set_animation(animation_type) do + GenServer.cast(__MODULE__, {:set_animation, animation_type}) end @doc """ @@ -61,27 +56,33 @@ defmodule RGBMatrix.Engine do end @doc """ - Unregister a `RGBMatrix.Paintable` so the engine no longer paints pixels to it. - This function is idempotent. + Unregister a `RGBMatrix.Paintable` so the engine no longer paints pixels to + it. This function is idempotent. """ @spec unregister_paintable(paintable :: module) :: :ok def unregister_paintable(paintable) do GenServer.call(__MODULE__, {:unregister_paintable, paintable}) end + @spec interact(led :: LED.t()) :: :ok + def interact(led) do + GenServer.cast(__MODULE__, {:interact, led}) + end + # Server @impl GenServer - def init({initial_animation, paintables}) do - send(self(), :get_next_frame) + def init({leds, initial_animation_type, paintables}) do + black = Chameleon.HSV.new(0, 0, 0) + frame = Map.new(leds, &{&1.id, black}) - initial_state = %State{paintables: %{}} + initial_state = %State{leds: leds, last_frame: frame, paintables: %{}} state = Enum.reduce(paintables, initial_state, fn paintable, state -> add_paintable(paintable, state) end) - |> set_animation(initial_animation) + |> init_and_set_animation(initial_animation_type) {:ok, state} end @@ -96,64 +97,76 @@ defmodule RGBMatrix.Engine do %State{state | paintables: paintables} end - defp set_animation(state, animation) do + defp init_and_set_animation(state, animation_type) do + {render_in, animation} = Animation.new(animation_type, state.leds) + + state = schedule_next_render(state, render_in) + %State{state | animation: animation} end - @impl GenServer - def handle_info(:get_next_frame, state) do - animation = Animation.next_frame(state.animation) + defp schedule_next_render(state, :ignore) do + state + end - state.paintables - |> Map.values() - |> Enum.each(fn paint_fn -> - paint_fn.(animation.next_frame) - end) + defp schedule_next_render(state, :never) do + cancel_timer(state) + end - Process.send_after(self(), :get_next_frame, animation.delay_ms) - {:noreply, set_animation(state, animation)} + defp schedule_next_render(state, 0) do + send(self(), :render) + cancel_timer(state) end - @impl GenServer - def handle_info({:reset_animation, reset_animation}, state) do - {:noreply, set_animation(state, reset_animation)} + defp schedule_next_render(state, ms) when is_integer(ms) and ms > 0 do + state = cancel_timer(state) + %{state | timer: Process.send_after(self(), :render, ms)} end - @impl GenServer - def handle_info({:reply, from, reset_animation}, state) do - GenServer.reply(from, :ok) + defp cancel_timer(%{timer: nil} = state), do: state - {:noreply, set_animation(state, reset_animation)} + defp cancel_timer(state) do + Process.cancel_timer(state.timer) + %{state | timer: nil} end - @impl GenServer - def handle_cast({:play_animation, %{loop: loop} = animation}, state) - when is_integer(loop) and loop >= 1 do - current_animation = state.animation - expected_duration = Animation.duration(animation) - Process.send_after(self(), {:reset_animation, current_animation}, expected_duration) + @impl true + def handle_info(:render, state) do + {render_in, new_colors, animation} = Animation.render(state.animation) - {:noreply, set_animation(state, animation)} - end + frame = update_frame(state.last_frame, new_colors) + + state.paintables + |> Map.values() + |> Enum.each(fn paint_fn -> + paint_fn.(frame) + end) + + state = schedule_next_render(state, render_in) + state = %State{state | animation: animation, last_frame: frame} - @impl GenServer - def handle_cast({:play_animation, %{loop: 0} = _animation}, state) do {:noreply, state} end + defp update_frame(frame, new_colors) do + Enum.reduce(new_colors, frame, fn {led_id, color}, frame -> + Map.put(frame, led_id, color) + end) + end + @impl GenServer - def handle_cast({:play_animation, animation}, state) do - {:noreply, set_animation(state, animation)} + def handle_cast({:set_animation, animation_type}, state) do + state = init_and_set_animation(state, animation_type) + {:noreply, state} end @impl GenServer - def handle_call({:play_animation, %{loop: loop} = animation}, from, state) - when is_integer(loop) and loop >= 1 do - current_animation = state.animation - duration = Animation.duration(animation) - Process.send_after(self(), {:reply, from, current_animation}, duration) + def handle_cast({:interact, led}, state) do + {render_in, animation} = Animation.interact(state.animation, led) + state = schedule_next_render(state, render_in) + state = %State{state | animation: animation} - {:noreply, set_animation(state, animation)} + {:noreply, %State{state | animation: animation}} end @impl GenServer diff --git a/lib/rgb_matrix/frame.ex b/lib/rgb_matrix/frame.ex deleted file mode 100644 index 5fcf988..0000000 --- a/lib/rgb_matrix/frame.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule RGBMatrix.Frame do - @moduledoc """ - Provides a data structure and functions for working with animation frames. - - An animation frame is a mapping of pixel coordinates to their corresponding color. An animation can be composed of a - list of frames or each frame can be dynamically generated based on the tick of some animation. - """ - - alias RGBMatrix.Pixel - - @type pixel_map :: %{required(Pixel.t()) => Pixel.color()} - - @type t :: %__MODULE__{ - pixel_map: pixel_map() - } - - defstruct [:pixel_map] - - @spec new(pixels :: list(Pixel.t()), pixel_colors :: list(Pixel.color())) :: - t() - def new(pixels, pixel_colors) do - pixel_map = - Enum.zip(pixels, pixel_colors) - |> Enum.into(%{}) - - %__MODULE__{pixel_map: pixel_map} - end - - @spec solid_color(pixels :: list(Pixel.t()), color :: Pixel.color()) :: t() - def solid_color(pixels, color) do - pixel_colors = List.duplicate(color, length(pixels)) - new(pixels, pixel_colors) - end -end diff --git a/lib/rgb_matrix/paintable.ex b/lib/rgb_matrix/paintable.ex index ad58a3d..8d951cf 100644 --- a/lib/rgb_matrix/paintable.ex +++ b/lib/rgb_matrix/paintable.ex @@ -1,14 +1,18 @@ defmodule RGBMatrix.Paintable do @moduledoc """ - A paintable module controls physical pixels. + A paintable module controls physical LEDs. """ + alias Layout.LED + + @type frame :: %{required(LED.id()) => RGBMatrix.any_color_model()} + @doc """ - Returns a function that can be called to paint the pixels for a given frame. - The anonymous function's return value is unused. + Returns a function that can be called to paint the LEDs for a given frame. The + anonymous function's return value is unused. This callback makes any hardware implementation details opaque to the caller, - while allowing the paintable to retain control of the physical pixels. + while allowing the paintable to retain control of the physical LEDs. """ - @callback get_paint_fn :: (frame :: RGBMatrix.Frame.t() -> any) + @callback get_paint_fn :: (frame -> any) end diff --git a/lib/rgb_matrix/pixel.ex b/lib/rgb_matrix/pixel.ex deleted file mode 100644 index daa912a..0000000 --- a/lib/rgb_matrix/pixel.ex +++ /dev/null @@ -1,27 +0,0 @@ -defmodule RGBMatrix.Pixel do - @moduledoc """ - A pixel is a unit that has X and Y coordinates and displays a single color. - """ - - @typedoc """ - A tuple containing the X and Y coordinates of the pixel. - """ - @type t :: {x :: non_neg_integer, y :: non_neg_integer} - - @typedoc """ - The color of the pixel, represented as a `Chameleon.Color` color model. - """ - @type color :: any_color_model - - @typedoc """ - Shorthand for any `Chameleon.Color` color model. - """ - @type any_color_model :: - Chameleon.Color.RGB.t() - | Chameleon.Color.CMYK.t() - | Chameleon.Color.Hex.t() - | Chameleon.Color.HSL.t() - | Chameleon.Color.HSV.t() - | Chameleon.Color.Keyword.t() - | Chameleon.Color.Pantone.t() -end diff --git a/lib/xebow.ex b/lib/xebow.ex index 395d736..7441169 100644 --- a/lib/xebow.ex +++ b/lib/xebow.ex @@ -1,2 +1,38 @@ defmodule Xebow do + alias Layout.{Key, LED} + + @leds [ + LED.new(:l001, 0, 0), + LED.new(:l002, 1, 0), + LED.new(:l003, 2, 0), + LED.new(:l004, 0, 1), + LED.new(:l005, 1, 1), + LED.new(:l006, 2, 1), + LED.new(:l007, 0, 2), + LED.new(:l008, 1, 2), + LED.new(:l009, 2, 2), + LED.new(:l010, 0, 3), + LED.new(:l011, 1, 3), + LED.new(:l012, 2, 3) + ] + + @keys [ + Key.new(:k001, 0, 0, led: :l001), + Key.new(:k002, 1, 0, led: :l002), + Key.new(:k003, 2, 0, led: :l003), + Key.new(:k004, 0, 1, led: :l004), + Key.new(:k005, 1, 1, led: :l005), + Key.new(:k006, 2, 1, led: :l006), + Key.new(:k007, 0, 2, led: :l007), + Key.new(:k008, 1, 2, led: :l008), + Key.new(:k009, 2, 2, led: :l009), + Key.new(:k010, 0, 3, led: :l010), + Key.new(:k011, 1, 3, led: :l011), + Key.new(:k012, 2, 3, led: :l012) + ] + + @layout Layout.new(@keys, @leds) + + @spec layout() :: Layout.t() + def layout, do: @layout end diff --git a/lib/xebow/application.ex b/lib/xebow/application.ex index 945f9e8..a46957f 100644 --- a/lib/xebow/application.ex +++ b/lib/xebow/application.ex @@ -5,8 +5,8 @@ defmodule Xebow.Application do use Application + @leds Xebow.layout() |> Layout.leds() @animation_type RGBMatrix.Animation.types() |> List.first() - @animation RGBMatrix.Animation.new(type: @animation_type) def start(_type, _args) do # See https://hexdocs.pm/elixir/Supervisor.html @@ -39,7 +39,7 @@ defmodule Xebow.Application do # {Xebow.Worker, arg}, Xebow.HIDGadget, Xebow.LEDs, - {RGBMatrix.Engine, {@animation, [Xebow.LEDs]}}, + {RGBMatrix.Engine, {@leds, @animation_type, [Xebow.LEDs]}}, Xebow.Keyboard ] end diff --git a/lib/xebow/keyboard.ex b/lib/xebow/keyboard.ex index 189d964..83d7a71 100644 --- a/lib/xebow/keyboard.ex +++ b/lib/xebow/keyboard.ex @@ -17,24 +17,22 @@ defmodule Xebow.Keyboard do use GenServer alias Circuits.GPIO - alias RGBMatrix.{Animation, Frame} + alias RGBMatrix.{Animation, Engine} # maps the physical GPIO pins to key IDs - # TODO: re-number these keys so they map to the keyboard in X/Y natural order, - # rather than keybow hardware order. @gpio_pins %{ + 6 => :k004, + 5 => :k009, + 27 => :k011, + 26 => :k003, + 24 => :k008, + 23 => :k012, + 22 => :k007, 20 => :k001, - 6 => :k002, - 22 => :k003, - 17 => :k004, - 16 => :k005, - 12 => :k006, - 24 => :k007, - 27 => :k008, - 26 => :k009, - 13 => :k010, - 5 => :k011, - 23 => :k012 + 17 => :k010, + 16 => :k002, + 13 => :k006, + 12 => :k005 } # this file exists because `Xebow.HIDGadget` set it up during boot. @@ -44,45 +42,45 @@ defmodule Xebow.Keyboard do # Layer 0: %{ k001: AFK.Keycode.Key.new(:"7"), - k002: AFK.Keycode.Key.new(:"4"), - k003: AFK.Keycode.Key.new(:"1"), - k004: AFK.Keycode.Key.new(:"0"), - k005: AFK.Keycode.Key.new(:"8"), - k006: AFK.Keycode.Key.new(:"5"), - k007: AFK.Keycode.Key.new(:"2"), - k008: AFK.Keycode.Layer.new(:hold, 1), - k009: AFK.Keycode.Key.new(:"9"), - k010: AFK.Keycode.Key.new(:"6"), - k011: AFK.Keycode.Key.new(:"3"), + k002: AFK.Keycode.Key.new(:"8"), + k003: AFK.Keycode.Key.new(:"9"), + k004: AFK.Keycode.Key.new(:"4"), + k005: AFK.Keycode.Key.new(:"5"), + k006: AFK.Keycode.Key.new(:"6"), + k007: AFK.Keycode.Key.new(:"1"), + k008: AFK.Keycode.Key.new(:"2"), + k009: AFK.Keycode.Key.new(:"3"), + k010: AFK.Keycode.Key.new(:"0"), + k011: AFK.Keycode.Layer.new(:hold, 1), k012: AFK.Keycode.Layer.new(:hold, 2) }, # Layer 1: %{ k001: AFK.Keycode.Transparent.new(), - k002: AFK.Keycode.Transparent.new(), - k003: AFK.Keycode.Transparent.new(), + k002: AFK.Keycode.Key.new(:mute), + k003: AFK.Keycode.Key.new(:volume_up), k004: AFK.Keycode.Transparent.new(), - k005: AFK.Keycode.Key.new(:mute), - k006: AFK.Keycode.Transparent.new(), + k005: AFK.Keycode.Transparent.new(), + k006: AFK.Keycode.Key.new(:volume_down), k007: AFK.Keycode.Transparent.new(), - k008: AFK.Keycode.None.new(), - k009: AFK.Keycode.Key.new(:volume_up), - k010: AFK.Keycode.Key.new(:volume_down), - k011: AFK.Keycode.Transparent.new(), + k008: AFK.Keycode.Transparent.new(), + k009: AFK.Keycode.Transparent.new(), + k010: AFK.Keycode.Transparent.new(), + k011: AFK.Keycode.None.new(), k012: AFK.Keycode.Transparent.new() }, # Layer 2: %{ k001: AFK.Keycode.MFA.new({__MODULE__, :flash, ["red"]}), - k002: AFK.Keycode.MFA.new({__MODULE__, :previous_animation, []}), - k003: AFK.Keycode.Transparent.new(), - k004: AFK.Keycode.Transparent.new(), + k002: AFK.Keycode.Transparent.new(), + k003: AFK.Keycode.MFA.new({__MODULE__, :flash, ["green"]}), + k004: AFK.Keycode.MFA.new({__MODULE__, :previous_animation, []}), k005: AFK.Keycode.Transparent.new(), - k006: AFK.Keycode.Transparent.new(), + k006: AFK.Keycode.MFA.new({__MODULE__, :next_animation, []}), k007: AFK.Keycode.Transparent.new(), k008: AFK.Keycode.Transparent.new(), - k009: AFK.Keycode.MFA.new({__MODULE__, :flash, ["green"]}), - k010: AFK.Keycode.MFA.new({__MODULE__, :next_animation, []}), + k009: AFK.Keycode.Transparent.new(), + k010: AFK.Keycode.Transparent.new(), k011: AFK.Keycode.Transparent.new(), k012: AFK.Keycode.Transparent.new() } @@ -136,18 +134,15 @@ defmodule Xebow.Keyboard do poll_timer_ms = 15 :timer.send_interval(poll_timer_ms, self(), :update_pin_values) - animations = - Animation.types() - |> Enum.map(&Animation.new(type: &1)) - - {:ok, - %{ - pins: pins, - keyboard_state: keyboard_state, - hid: hid, - animations: animations, - current_animation_index: 0 - }} + state = %{ + pins: pins, + keyboard_state: keyboard_state, + hid: hid, + animation_types: Animation.types(), + current_animation_index: 0 + } + + {:ok, state} end @impl GenServer @@ -155,14 +150,14 @@ defmodule Xebow.Keyboard do next_index = state.current_animation_index + 1 next_index = - case next_index < Enum.count(state.animations) do + case next_index < Enum.count(state.animation_types) do true -> next_index _ -> 0 end - animation = Enum.at(state.animations, next_index) + animation_type = Enum.at(state.animation_types, next_index) - RGBMatrix.Engine.play_animation(animation) + RGBMatrix.Engine.set_animation(animation_type) state = %{state | current_animation_index: next_index} @@ -175,13 +170,13 @@ defmodule Xebow.Keyboard do previous_index = case previous_index < 0 do - true -> Enum.count(state.animations) - 1 + true -> Enum.count(state.animation_types) - 1 _ -> previous_index end - animation = Enum.at(state.animations, previous_index) + animation_type = Enum.at(state.animation_types, previous_index) - RGBMatrix.Engine.play_animation(animation) + RGBMatrix.Engine.set_animation(animation_type) state = %{state | current_animation_index: previous_index} @@ -218,6 +213,7 @@ defmodule Xebow.Keyboard do 0 -> Logger.debug("key pressed #{key_id}") AFK.State.press_key(keyboard_state, key_id) + rgb_matrix_interact(key_id) 1 -> Logger.debug("key released #{key_id}") @@ -225,15 +221,16 @@ defmodule Xebow.Keyboard do end end + defp rgb_matrix_interact(key_id) do + case Layout.led_for_key(Xebow.layout(), key_id) do + nil -> :noop + led -> Engine.interact(led) + end + end + # Custom Key Functions def flash(color) do - pixels = Xebow.Utils.pixels() - color = Chameleon.Keyword.new(color) - frame = Frame.solid_color(pixels, color) - - animation = Animation.new(type: Animation.Static, frames: [frame], delay_ms: 250, loop: 1) - - RGBMatrix.Engine.play_animation(animation, async: false) + Logger.info("TODO: flash color #{IO.inspect(color)}") end end diff --git a/lib/xebow/leds.ex b/lib/xebow/leds.ex index 3c19e60..e296f37 100644 --- a/lib/xebow/leds.ex +++ b/lib/xebow/leds.ex @@ -4,7 +4,7 @@ defmodule Xebow.LEDs do Keybow. It also implements the RGBMatrix.Paintable behavior so that the RGBMatrix - effects can be painted onto the keybow's RGB LEDs. + animations can be painted onto the keybow's RGB LEDs. """ @behaviour RGBMatrix.Paintable @@ -22,6 +22,22 @@ defmodule Xebow.LEDs do @spi_speed_hz 4_000_000 @sof <<0, 0, 0, 0>> @eof <<255, 255, 255, 255>> + # This is the hardware order that the LED colors need to be sent to the SPI + # device in. The LED IDs are the ones from `Xebow.layout/0`. + @spi_led_order [ + :l001, + :l004, + :l007, + :l010, + :l002, + :l005, + :l008, + :l011, + :l003, + :l006, + :l009, + :l012 + ] # Client @@ -56,9 +72,8 @@ defmodule Xebow.LEDs do defp paint(spidev, frame) do colors = - frame.pixel_map - |> Enum.sort() - |> Enum.map(fn {_cord, color} -> color end) + @spi_led_order + |> Enum.map(&Map.fetch!(frame, &1)) data = Enum.reduce(colors, @sof, fn color, acc -> diff --git a/lib/xebow/utils.ex b/lib/xebow/utils.ex deleted file mode 100644 index ea34253..0000000 --- a/lib/xebow/utils.ex +++ /dev/null @@ -1,42 +0,0 @@ -defmodule Xebow.Utils do - @moduledoc """ - Shared utility functions that are generally useful. - """ - - @doc """ - Modulo operation that supports negative numbers. - - This is effectively `mod` as it exists in most other languages. Elixir's `rem` - doesn't act the same as other languages for negative numbers. - """ - @spec mod(integer, integer) :: non_neg_integer - def mod(number, modulus) when is_integer(number) and is_integer(modulus) do - case rem(number, modulus) do - remainder when (remainder > 0 and modulus < 0) or (remainder < 0 and modulus > 0) -> - remainder + modulus - - remainder -> - remainder - end - end - - # pixels on the xebow start in upper left corner and count down instead of - # across - @pixels [ - {0, 0}, - {0, 1}, - {0, 2}, - {0, 3}, - {1, 0}, - {1, 1}, - {1, 2}, - {1, 3}, - {2, 0}, - {2, 1}, - {2, 2}, - {2, 3} - ] - - @spec pixels() :: list(RGBMatrix.Pixel.t()) - def pixels, do: @pixels -end diff --git a/test/rgb_matrix/engine_test.exs b/test/rgb_matrix/engine_test.exs index 79ce961..d2f69fd 100644 --- a/test/rgb_matrix/engine_test.exs +++ b/test/rgb_matrix/engine_test.exs @@ -1,109 +1,111 @@ defmodule RGBMatrix.EngineTest do - use ExUnit.Case + # use ExUnit.Case - alias RGBMatrix.{Animation, Engine, Frame} - - # Creates a RGBMatrix.Paintable module that emits frames to the test suite process. - defp paintable(%{test: test_name}) do - process = self() - module_name = String.to_atom("#{test_name}-paintable") + # alias RGBMatrix.{Animation, Engine, Frame} - Module.create( - module_name, - quote do - def get_paint_fn do - fn frame -> - send(unquote(process), {:frame, frame}) - end - end - end, - Macro.Env.location(__ENV__) - ) + # @leds Xebow.layout() |> Layout.leds() - %{paintable: module_name} - end + # # Creates a RGBMatrix.Paintable module that emits frames to the test suite process. + # defp paintable(%{test: test_name}) do + # process = self() + # module_name = String.to_atom("#{test_name}-paintable") - # Creates a single pixel, single frame animation. - defp solid_animation(red \\ 255, green \\ 127, blue \\ 0) do - pixels = [{0, 0}] - color = Chameleon.RGB.new(red, green, blue) - frame = Frame.solid_color(pixels, color) + # Module.create( + # module_name, + # quote do + # def get_paint_fn do + # fn frame -> + # send(unquote(process), {:frame, frame}) + # end + # end + # end, + # Macro.Env.location(__ENV__) + # ) - animation = - Animation.new( - type: Animation.Static, - frames: [frame], - delay_ms: 10, - loop: 1 - ) + # %{paintable: module_name} + # end - {animation, frame} - end + # # Creates a single pixel, single frame animation. + # defp solid_animation(red \\ 255, green \\ 127, blue \\ 0) do + # pixels = [{0, 0}] + # color = Chameleon.RGB.new(red, green, blue) + # frame = Frame.solid_color(pixels, color) - setup [:paintable] + # animation = + # Animation.new( + # type: Animation.Static, + # frames: [frame], + # delay_ms: 10, + # loop: 1 + # ) - test "renders a solid animation", %{paintable: paintable} do - {animation, frame} = solid_animation() + # {animation, frame} + # end - start_supervised!({Engine, {animation, [paintable]}}) + # setup [:paintable] - assert_receive {:frame, ^frame} - end + # test "renders a solid animation", %{paintable: paintable} do + # {animation, frame} = solid_animation() - test "renders a multi-frame, multi-pixel animation", %{paintable: paintable} do - pixels = [ - {0, 0}, - {0, 1}, - {1, 0}, - {1, 1} - ] + # start_supervised!({Engine, {@leds, animation, [paintable]}}) - frames = [ - Frame.solid_color(pixels, Chameleon.Keyword.new("red")), - Frame.solid_color(pixels, Chameleon.Keyword.new("green")), - Frame.solid_color(pixels, Chameleon.Keyword.new("blue")), - Frame.solid_color(pixels, Chameleon.Keyword.new("white")) - ] + # assert_receive {:frame, ^frame} + # end - animation = - Animation.new( - type: Animation.Static, - frames: frames, - delay_ms: 10, - loop: 1 - ) + # test "renders a multi-frame, multi-pixel animation", %{paintable: paintable} do + # pixels = [ + # {0, 0}, + # {0, 1}, + # {1, 0}, + # {1, 1} + # ] - start_supervised!({Engine, {animation, [paintable]}}) + # frames = [ + # Frame.solid_color(pixels, Chameleon.Keyword.new("red")), + # Frame.solid_color(pixels, Chameleon.Keyword.new("green")), + # Frame.solid_color(pixels, Chameleon.Keyword.new("blue")), + # Frame.solid_color(pixels, Chameleon.Keyword.new("white")) + # ] - Enum.each(frames, fn frame -> - assert_receive {:frame, ^frame} - end) - end + # animation = + # Animation.new( + # type: Animation.Static, + # frames: frames, + # delay_ms: 10, + # loop: 1 + # ) - test "can play a different animation", %{paintable: paintable} do - {animation, _frame} = solid_animation() - {animation_2, frame_2} = solid_animation(127, 127, 127) + # start_supervised!({Engine, {@leds, animation, [paintable]}}) - start_supervised!({Engine, {animation, [paintable]}}) + # Enum.each(frames, fn frame -> + # assert_receive {:frame, ^frame} + # end) + # end - :ok = Engine.play_animation(animation_2) + # test "can play a different animation", %{paintable: paintable} do + # {animation, _frame} = solid_animation() + # {animation_2, frame_2} = solid_animation(127, 127, 127) - assert_receive {:frame, ^frame_2} - end + # start_supervised!({Engine, {@leds, animation, [paintable]}}) - test "can register and unregister paintables", %{paintable: paintable} do - {animation, frame} = solid_animation() - {animation_2, frame_2} = solid_animation(127, 127, 127) + # :ok = Engine.play_animation(animation_2) - start_supervised!({Engine, {animation, []}}) + # assert_receive {:frame, ^frame_2} + # end - :ok = Engine.register_paintable(paintable) + # test "can register and unregister paintables", %{paintable: paintable} do + # {animation, frame} = solid_animation() + # {animation_2, frame_2} = solid_animation(127, 127, 127) - assert_receive {:frame, ^frame} + # start_supervised!({Engine, {@leds, animation, []}}) - :ok = Engine.unregister_paintable(paintable) - :ok = Engine.play_animation(animation_2) - - refute_receive {:frame, ^frame_2} - end + # :ok = Engine.register_paintable(paintable) + + # assert_receive {:frame, ^frame} + + # :ok = Engine.unregister_paintable(paintable) + # :ok = Engine.play_animation(animation_2) + + # refute_receive {:frame, ^frame_2} + # end end diff --git a/test/xebow_test.exs b/test/xebow_test.exs new file mode 100644 index 0000000..daf7879 --- /dev/null +++ b/test/xebow_test.exs @@ -0,0 +1,7 @@ +defmodule XebowTest do + use ExUnit.Case + + test "has layout" do + assert %Layout{} = Xebow.layout() + end +end