feat(model,serialise): Implement a basic G-Code model and serialiser.

It's not very thorough at the moment, but it should work for now.
This commit is contained in:
James Harton 2021-01-04 22:14:40 +13:00
parent a927113d8a
commit ccf4635cca
31 changed files with 727 additions and 13 deletions

View file

@ -1,18 +1,20 @@
defmodule Gcode do
alias Gcode.{Model.Program, Model.Serialise, Result}
@moduledoc """
Documentation for `Gcode`.
"""
@doc """
Hello world.
## Examples
iex> Gcode.hello()
:world
Serialise a program to a String.
"""
def hello do
:world
@spec serialise(Program.t()) :: Result.t(String.t(), {:serialise_error, any})
def serialise(%Program{} = program) do
program
|> Serialise.serialise()
|> Result.Enum.map(fn block ->
{:ok, "#{block}\r\n"}
end)
|> Result.Enum.join("")
end
end

98
lib/model/block.ex Normal file
View file

@ -0,0 +1,98 @@
defmodule Gcode.Model.Block do
use Gcode.Option
use Gcode.Result
defstruct words: [], comment: none()
alias Gcode.Model.{Block, Comment, Skip, Word}
@moduledoc """
A sequence of G-code words.
"""
@type t :: %Block{words: [block_contents], comment: Option.t(Comment)}
@typedoc "Any error results in this module will return this type"
@type block_error :: {:block_error, String.t()}
@type block_contents :: Word.t() | Skip.t()
@doc """
Initialise a new empty G-code program.
## Example
iex> Block.init()
%Block{words: [], comment: none()}
"""
@spec init :: t
def init, do: %Block{words: [], comment: none()}
@doc """
Set a comment on the block (this is just a sugar to make sure that the comment
is rendered on the same line as the block).
*Note:* Once a block has a comment set, it cannot be overwritten.
## Examples
iex> comment = Comment.init("Jen, in the swing seat, with her night terrors")
...> block = Block.init()
...> {:ok, block} = Block.comment(block, comment)
...> Result.ok?(block.comment)
true
iex> comment = Comment.init("Jen, in the swing seat, with her night terrors")
...> block = Block.init()
...> {:ok, block} = Block.comment(block, comment)
...> Block.comment(block, comment)
{:error, {:block_error, "Block already contains a comment"}}
"""
@spec comment(t, Comment.t()) :: Result.t(t, block_error)
def comment(%Block{comment: none()} = block, %Comment{comment: some(_)} = comment),
do: {:ok, %Block{block | comment: some(comment)}}
def comment(%Block{comment: some(_)}, _comment),
do: {:error, {:block_error, "Block already contains a comment"}}
@doc """
Pushes a `Word` onto the word list.
*Note:* `Block` stores the words in reverse order because of Erlang list
semantics, you should pretty much always use `words/1` to retrieve them in the
correct order.
## Example
iex> block = Block.init()
...> {:ok, block} = Block.push(block, Word.init("G", 0))
...> Block.push(block, Word.init("N", 100))
{:ok, %Block{words: [Word.init("N", 100), Word.init("G", 0)]}}
"""
@spec push(t, Word.t()) :: Result.t(t, block_error)
def push(%Block{words: words} = block, word)
when (is_struct(word, Word) or is_struct(word, Skip)) and is_list(words),
do: {:ok, %Block{block | words: [word | words]}}
def push(%Block{words: words}, word)
when (is_struct(word, Word) or is_struct(word, Skip)) and is_list(words),
do:
{:error,
{:block_error,
"Expected block to contain a list of words, but it contains #{inspect(words)}"}}
@doc """
An accessor which returns the block's words in the correct order.
iex> block = Block.init()
...> {:ok, block} = Block.push(block, Word.init("G", 0))
...> {:ok, block} = Block.push(block, Word.init("N", 100))
...> Block.words(block)
{:ok, [Word.init("G", 0), Word.init("N", 100)]}
"""
@spec words(t) :: Result.t([Word.t()], {:block_error, String.t()})
def words(%Block{words: words}) when is_list(words), do: {:ok, Enum.reverse(words)}
def words(%Block{words: words}),
do:
{:error,
{:block_error,
"Expected block to contain a list of words, but it contains #{inspect(words)}"}}
end

View file

@ -0,0 +1,26 @@
defimpl Gcode.Model.Serialise, for: Gcode.Model.Block do
alias Gcode.{Model.Block, Model.Serialise, Result}
use Gcode.Option
use Gcode.Result
@spec serialise(Block.t()) :: Result.t([String.t()], {:serialise_error, any})
def serialise(%Block{words: words, comment: some(comment)}) do
words
|> encode_words()
|> Result.map(fn words -> ok(["#{words} #{comment}"]) end)
end
def serialise(%Block{words: words, comment: none()}) do
words
|> encode_words()
|> Result.map(fn words -> ok([words]) end)
end
defp encode_words(words) when is_list(words) do
words
|> Enum.reverse()
|> ok()
|> Result.Enum.map(&Serialise.serialise/1)
|> Result.Enum.join(" ")
end
end

25
lib/model/comment.ex Normal file
View file

@ -0,0 +1,25 @@
defmodule Gcode.Model.Comment do
alias Gcode.Model.Comment
use Gcode.Option
defstruct comment: Option.none()
@moduledoc """
A G-code comment.
"""
@type t :: %Comment{
comment: Option.t(String.t())
}
@doc """
Initialise a comment.
## Example
iex> "Doc, in the carpark, with plutonium"
...> |> Comment.init()
%Comment{comment: some("Doc, in the carpark, with plutonium")}
"""
@spec init(String.t()) :: t
def init(comment) when is_binary(comment), do: %Comment{comment: Option.some(comment)}
end

View file

@ -0,0 +1,16 @@
defimpl Gcode.Model.Serialise, for: Gcode.Model.Comment do
alias Gcode.Model.Comment
use Gcode.Option
use Gcode.Result
@spec serialise(Comment.t()) :: Result.t([String.t()], {:serialise_error, any})
def serialise(%Comment{comment: some(comment)}) do
comment
|> String.split(~r/(\r\n|\r|\n)/)
|> Enum.reject(&(byte_size(&1) == 0))
|> Enum.map(&"(#{&1})")
|> ok()
end
def serialise(%Comment{comment: none()}), do: {:error, {:serialise_error, :empty_comment}}
end

42
lib/model/program.ex Normal file
View file

@ -0,0 +1,42 @@
defmodule Gcode.Model.Program do
defstruct elements: []
alias Gcode.Model.{Block, Program, Tape}
@moduledoc """
A G-code program is the high level object which contains each of the G-code
blocks, comments, etc.
## Example
iex> Program.init()
...> |> Enum.count()
0
"""
@typedoc "A G-code program"
@type t :: %Program{
elements: [element]
}
@type element :: Block.t() | Comment.t() | Tape.t()
@doc """
Initialise a new, empty G-code program.
iex> Program.init()
%Program{elements: []}
"""
@spec init :: t()
def init, do: %Program{}
@doc """
Push a program element onto the end of the program.
iex> Program.init()
...> |> Program.push(Tape.init())
{:ok, %Program{elements: [%Tape{}]}}
"""
@spec push(t, element) :: {:ok, t} | {:error, any}
def push(%Program{elements: elements} = program, element),
do: {:ok, %Program{program | elements: [element | elements]}}
end

View file

@ -0,0 +1,17 @@
defimpl Enumerable, for: Gcode.Model.Program do
alias Gcode.Model.Program
@moduledoc false
@spec count(Program.t()) :: {:ok, non_neg_integer()} | {:error, module}
def count(%Program{elements: elements}), do: {:ok, Enum.count(elements)}
@spec member?(Program.t(), any) :: {:error, module} | {:ok, boolean}
def member?(%Program{elements: elements}, element), do: Enumerable.member?(elements, element)
@spec reduce(Program.t(), Enumerable.acc(), Enumerable.reducer()) :: Enumerable.result()
def reduce(%Program{elements: elements}, acc, fun), do: Enumerable.reduce(elements, acc, fun)
@spec slice(Program.t()) ::
{:ok, non_neg_integer, Enumerable.slicing_fun()} | {:error, module}
def slice(%Program{elements: elements}), do: Enumerable.slice(elements)
end

View file

@ -0,0 +1,12 @@
defimpl Gcode.Model.Serialise, for: Gcode.Model.Program do
alias Gcode.{Model.Program, Model.Serialise}
use Gcode.Result
@spec serialise(Program.t()) :: Result.t([String.t()], {:serialise_error, any})
def serialise(%Program{elements: elements}) do
with elements <- Enum.reverse(elements),
ok(result) <- Result.Enum.map(ok(elements), &Serialise.serialise/1) do
ok(List.flatten(result))
end
end
end

10
lib/model/serialise.ex Normal file
View file

@ -0,0 +1,10 @@
defprotocol Gcode.Model.Serialise do
alias Gcode.Result
@moduledoc """
A protocol which is used to serialise the model into a string.
"""
@spec serialise(Serialise.t()) :: Result.t([String.t()])
def serialise(value)
end

36
lib/model/skip.ex Normal file
View file

@ -0,0 +1,36 @@
defmodule Gcode.Model.Skip do
alias Gcode.Model.Skip
use Gcode.Option
defstruct number: Option.none()
@moduledoc """
A G-code skip.
"""
@type t :: %Skip{
number: Option.t(non_neg_integer)
}
@doc """
Initialise a skip with a number.
## Example
iex> 13
...> |> Skip.init()
%Skip{number: some(13)}
"""
@spec init(non_neg_integer) :: t
def init(number) when is_number(number) and number >= 0, do: %Skip{number: Option.some(number)}
@doc """
Initialise a skip without a number.
## Example
iex> Skip.init()
%Skip{number: none()}
"""
@spec init :: t
def init, do: %Skip{}
end

View file

@ -0,0 +1,10 @@
defimpl Gcode.Model.Serialise, for: Gcode.Model.Skip do
alias Gcode.{Model.Skip, Result}
use Gcode.Option
use Gcode.Result
@moduledoc false
@spec serialise(Skip.t()) :: Result.t([String.t()], {:serialise_error, any})
def serialise(%Skip{number: none()}), do: ok(["/"])
def serialise(%Skip{number: some(number)}), do: ok(["/#{number}"])
end

34
lib/model/tape.ex Normal file
View file

@ -0,0 +1,34 @@
defmodule Gcode.Model.Tape do
defstruct leader: :none
alias Gcode.{Model.Tape}
use Gcode.Option
@moduledoc """
The tape (`%`) denotes the beginning and end of the program and is not needed
by most controllers. Can optionally contain a comment, called a "leader".
"""
@type t :: %Tape{leader: Option.t(String.t())}
@doc """
Initialises a tape command, with no "leader"
## Example
iex> Tape.init()
%Tape{leader: :none}
"""
@spec init :: t
def init, do: %Tape{leader: Option.none()}
@doc """
Initialises a tape command, with a "leader"
## Example
iex> Tape.init("Marty in the Delorean with the Flux Capacitor")
%Tape{leader: {:ok, "Marty in the Delorean with the Flux Capacitor"}}
"""
@spec init(String.t()) :: t
def init(leader) when is_binary(leader), do: %Tape{leader: Option.some(leader)}
end

View file

@ -0,0 +1,10 @@
defimpl Gcode.Model.Serialise, for: Gcode.Model.Tape do
alias Gcode.{Model.Tape, Result}
use Gcode.Option
use Gcode.Result
@moduledoc false
@spec serialise(Tape.t()) :: Result.t([String.t()], {:serialise_error, any})
def serialise(%Tape{leader: none()}), do: ok(["%"])
def serialise(%Tape{leader: some(leader)}), do: ok(["% #{leader}"])
end

27
lib/model/word.ex Normal file
View file

@ -0,0 +1,27 @@
defmodule Gcode.Model.Word do
alias Gcode.Model.Word
use Gcode.Option
defstruct word: none(), address: none()
@moduledoc """
A G-code word.
"""
@type t :: %Word{
word: Option.some(String.t()),
address: Option.some(number)
}
@doc """
Initialise a word with a command and an address.
## Example
iex> Word.init("G", 0)
%Word{word: {:ok, "G"}, address: {:ok, 0}}
"""
@spec init(String.t(), number) :: t
def init(word, address) when is_binary(word) and byte_size(word) == 1 and is_number(address),
do: %Word{word: some(word), address: some(address)}
end

View file

@ -0,0 +1,16 @@
defimpl Gcode.Model.Serialise, for: Gcode.Model.Word do
alias Gcode.Model.Word
use Gcode.Option
use Gcode.Result
@spec serialise(Word.t()) :: Result.t([String.t()], {:serialise_error, any})
def serialise(%Word{word: some(word), address: some(address)}) when is_integer(address) do
ok(["#{word}#{address}"])
end
def serialise(%Word{word: some(word), address: some(address)}) when is_float(address) do
ok(["#{word}#{address}"])
end
def serialise(_word), do: error({:serialise_error, "invalid word"})
end

39
lib/option.ex Normal file
View file

@ -0,0 +1,39 @@
defmodule Gcode.Option do
@moduledoc """
A helper which represents an optional type.
"""
defmacro __using__(_) do
quote do
alias Gcode.Option
require Gcode.Option
import Gcode.Option, only: [some: 1, none: 0]
end
end
@type t(value) :: some(value) | opt_none
@type some(t) :: {:ok, t}
@type opt_none :: :none
@spec none?(t(any)) :: boolean
def none?(:none), do: true
def none?({:ok, _}), do: false
@spec some?(t(any)) :: boolean
def some?(:none), do: false
def some?({:ok, _}), do: true
@spec none :: Macro.t()
defmacro none do
quote do
:none
end
end
@spec some(any) :: Macro.t()
defmacro some(pattern) do
quote do
{:ok, unquote(pattern)}
end
end
end

55
lib/result.ex Normal file
View file

@ -0,0 +1,55 @@
defmodule Gcode.Result do
@moduledoc """
A helper which represents a result type.
"""
@type t :: t(any, any)
@type t(result) :: t(result, any)
@type t(result, error) :: ok(result) | error(error)
@type ok(result) :: {:ok, result}
@type error(error) :: {:error, error}
defmacro __using__(_) do
quote do
alias Gcode.Result
require Gcode.Result
import Gcode.Result, only: [ok: 1, error: 1]
end
end
@spec ok(any) :: Macro.t()
defmacro ok(result) do
quote do
{:ok, unquote(result)}
end
end
@spec error(any) :: Macro.t()
defmacro error(error) do
quote do
{:error, unquote(error)}
end
end
@spec ok?(t) :: boolean
def ok?({:ok, _}), do: true
def ok?({:error, _}), do: false
@spec error?(t) :: boolean
def error?({:ok, _}), do: false
def error?({:error, _}), do: true
@spec unwrap!(t) :: any | no_return
def unwrap!({:ok, result}), do: result
def unwrap!({:error, error}), do: raise(error)
@spec map(t, (any -> t)) :: t
def map({:ok, value}, mapper) when is_function(mapper, 1) do
case mapper.(value) do
{:ok, value} -> {:ok, value}
{:error, error} -> {:error, error}
end
end
def map({:error, value}, mapper) when is_function(mapper, 1), do: {:error, value}
end

44
lib/result/enum.ex Normal file
View file

@ -0,0 +1,44 @@
defmodule Gcode.Result.Enum do
@moduledoc false
use Gcode.Result
@type result :: Result.t()
@type result(result) :: Result.t(result)
@type result(result, error) :: Result.t(result, error)
@doc "As long as the result of the reducer is ok, continue reducing, otherwise short circuit"
@spec reduce_while_ok(
enumerable :: any,
accumulator :: any,
reducer :: (any, any -> result(any))
) :: result(any)
def reduce_while_ok(elements, acc, reducer) when is_function(reducer, 2) do
Enum.reduce_while(elements, {:ok, acc}, fn element, {:ok, acc} ->
case reducer.(element, acc) do
{:ok, acc} -> {:cont, {:ok, acc}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
@spec map(result([any]), mapper :: (any -> result(any))) :: result([any])
def map({:ok, enumerable}, mapper) when is_function(mapper, 1) do
reduce_while_ok(enumerable, [], fn element, acc ->
case mapper.(element) do
{:ok, mapped} -> {:ok, [mapped | acc]}
{:error, reason} -> {:error, reason}
end
end)
|> reverse()
end
def map({:error, reason}, _mapper), do: {:error, reason}
@spec reverse(result([any])) :: result([any])
def reverse({:ok, enumerable}), do: {:ok, Enum.reverse(enumerable)}
def reverse({:error, reason}), do: {:error, reason}
@spec join(result([String.t()]), String.t()) :: result(String.t())
def join({:ok, strings}, joiner) when is_binary(joiner), do: {:ok, Enum.join(strings, joiner)}
def join({:error, reason}, _joiner), do: {:error, reason}
end

View file

@ -14,7 +14,8 @@ defmodule Gcode.MixProject do
start_permanent: Mix.env() == :prod,
package: package(),
description: @description,
deps: deps()
deps: deps(),
consolidate_protocols: Mix.env() != :test
]
end

View file

@ -1,8 +1,21 @@
defmodule GcodeTest do
use ExUnit.Case
use ExUnit.Case, async: true
use Gcode.Result
alias Gcode.Model.{Comment, Program, Tape}
doctest Gcode
@moduledoc false
test "greets the world" do
assert Gcode.hello() == :world
describe "serialise/1" do
test "it serialises a program correctly" do
program = Program.init()
ok(program) = Program.push(program, Tape.init())
ok(program) = Program.push(program, Comment.init("I am a very simple program"))
ok(program) = Program.push(program, Tape.init())
ok(actual) = Gcode.serialise(program)
expected = "%\r\n(I am a very simple program)\r\n%\r\n"
assert actual == expected
end
end
end

View file

@ -0,0 +1,24 @@
defmodule Gcode.Model.Block.SerialiseTest do
use ExUnit.Case, async: true
alias Gcode.Model.{Block, Serialise, Word}
use Gcode.Result
@moduledoc false
describe "serialise/1" do
assert ok(block) =
with(
block <- Block.init(),
word <- Word.init("G", 0),
ok(block) <- Block.push(block, word),
word <- Word.init("N", 100),
ok(block) <- Block.push(block, word),
do: ok(block)
)
assert ok([actual]) = Serialise.serialise(block)
expected = "G0 N100"
assert actual == expected
end
end

View file

@ -0,0 +1,8 @@
defmodule Gcode.Model.BlockTest do
use ExUnit.Case, async: true
alias Gcode.Model.{Block, Comment, Word}
use Gcode.Option
use Gcode.Result
doctest Gcode.Model.Block
@moduledoc false
end

View file

@ -0,0 +1,23 @@
defmodule Gcode.Model.Comment.SerialiseTest do
use ExUnit.Case, async: true
alias Gcode.Model.{Comment, Serialise}
@moduledoc false
describe "serialise/1" do
test "each line of the comment is wrapped in brackets" do
{:ok, actual} =
"""
This
is
a
test
"""
|> Comment.init()
|> Serialise.serialise()
expected = ~w[(This) (is) (a) (test)]
assert actual == expected
end
end
end

View file

@ -0,0 +1,7 @@
defmodule Gcode.Model.CommentTest do
use ExUnit.Case, async: true
alias Gcode.Model.Comment
use Gcode.Option
doctest Gcode.Model.Comment
@moduledoc false
end

View file

@ -0,0 +1,51 @@
defmodule Gcode.Model.Program.SerialiseTest do
use ExUnit.Case, async: true
use Gcode.Result
alias Gcode.Model.{Block, Comment, Program, Serialise, Skip, Tape, Word}
@moduledoc false
describe "serialise/1" do
test "it formats correctly" do
actual =
with program <- Program.init(),
tape <- Tape.init("The beginning"),
ok(program) <- Program.push(program, tape),
comment <- Comment.init("I am a single line comment"),
ok(program) <- Program.push(program, comment),
block <- Block.init(),
word <- Word.init("G", 0),
ok(block) <- Block.push(block, word),
word <- Word.init("N", 100),
ok(block) <- Block.push(block, word),
ok(program) <- Program.push(program, block),
comment <- Comment.init("I\nam\na\nmultiline\ncomment"),
ok(program) <- Program.push(program, comment),
block <- Block.init(),
skip <- Skip.init(),
ok(block) <- Block.push(block, skip),
word <- Word.init("N", 200),
ok(block) <- Block.push(block, word),
ok(program) <- Program.push(program, block),
tape <- Tape.init("The end"),
ok(program) <- Program.push(program, tape),
ok(result) <- Serialise.serialise(program) do
result
end
expected = [
"% The beginning",
"(I am a single line comment)",
"G0 N100",
"(I)",
"(am)",
"(a)",
"(multiline)",
"(comment)",
"/ N200",
"% The end"
]
assert actual == expected
end
end
end

View file

@ -0,0 +1,5 @@
defmodule Gcode.Model.ProgramTest do
use ExUnit.Case, async: true
alias Gcode.Model.{Program, Tape}
doctest Gcode.Model.Program
end

View file

@ -0,0 +1,27 @@
defmodule Gcode.Model.Skip.SerialiseTest do
use ExUnit.Case, async: true
alias Gcode.Model.{Serialise, Skip}
@moduledoc false
describe "serialise/1" do
test "when the skip has a number, it formats it correctly" do
{:ok, actual} =
Skip.init(0)
|> Serialise.serialise()
expected = ~w[/0]
assert actual == expected
end
test "when the skip has no number, it formats it correctly" do
{:ok, actual} =
Skip.init()
|> Serialise.serialise()
expected = ~w[/]
assert actual == expected
end
end
end

7
test/model/skip_test.exs Normal file
View file

@ -0,0 +1,7 @@
defmodule Gcode.Model.SkipTest do
use ExUnit.Case, async: true
alias Gcode.Model.Skip
use Gcode.Option
doctest Gcode.Model.Skip
@moduledoc false
end

5
test/model/tape_test.exs Normal file
View file

@ -0,0 +1,5 @@
defmodule Gcode.Model.TapeTest do
use ExUnit.Case, async: true
alias Gcode.Model.Tape
doctest Gcode.Model.Tape
end

View file

@ -0,0 +1,18 @@
defmodule Gcode.Model.Word.SerialiseTest do
use ExUnit.Case, async: true
alias Gcode.Model.{Serialise, Word}
@moduledoc false
describe "serialise/1" do
test "formats the word and the address correctly" do
{:ok, actual} =
"G"
|> Word.init(0)
|> Serialise.serialise()
expected = ~w[G0]
assert actual == expected
end
end
end

6
test/model/word_test.exs Normal file
View file

@ -0,0 +1,6 @@
defmodule Gcode.Model.WordTest do
use ExUnit.Case, async: true
alias Gcode.Model.Word
doctest Gcode.Model.Word
@moduledoc false
end