Created
September 12, 2019 22:39
-
-
Save ConnorRigby/282040dc49b81056fc12415dc7fd3484 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule FarmbotFirmware.Command do | |
@moduledoc false | |
alias FarmbotFirmware | |
alias FarmbotFirmware.GCODE | |
@spec command(GenServer.server(), GCODE.t() | {GCODE.kind(), GCODE.args()}) :: | |
:ok | {:error, :invalid_command | :firmware_error | :emergency_lock | FarmbotFirmware.status()} | |
def command(firmware_server \\ FarmbotFirmware, code) | |
def command(firmware_server, {_tag, {_, _}} = code) do | |
case GenServer.call(firmware_server, code, :infinity) do | |
{:ok, tag} -> wait_for_command_result(tag, code) | |
{:error, status} -> {:error, status} | |
end | |
end | |
def command(firmware_server, {_, _} = code) do | |
command(firmware_server, {to_string(:rand.uniform(100)), code}) | |
end | |
defp wait_for_command_result(tag, code, retries \\ 0, err \\ nil) do | |
receive do | |
{tag, {:report_begin, []}} -> | |
wait_for_command_result(tag, code, retries, err) | |
{tag, {:report_busy, []}} -> | |
wait_for_command_result(tag, code, retries, err) | |
{_, {:report_success, []}} -> | |
:ok | |
{tag, {:report_retry, []}} -> | |
wait_for_command_result(tag, code, retries + 1, err) | |
{tag, {:report_position_change, _} = error} -> | |
wait_for_command_result(tag, code, retries, error) | |
{_, {:report_error, []}} -> | |
if err, do: {:error, err}, else: {:error, :firmware_error} | |
{_, {:report_invalid, []}} -> | |
{:error, :invalid_command} | |
{_, {:report_emergency_lock, []}} -> | |
{:error, :emergency_lock} | |
{:error, reason} -> | |
{:error, reason} | |
{tag, _report} -> | |
wait_for_command_result(tag, code, retries, err) | |
after | |
30_000 -> | |
raise("Firmware command: #{GCODE.encode({tag, code})} failed to respond within 30 seconds") | |
end | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule FarmbotFirmware do | |
@moduledoc """ | |
Firmware wrapper for interacting with Farmbot-Arduino-Firmware. | |
This GenServer is expected to be a pretty simple state machine | |
with no side effects to anything in the rest of the Farmbot application. | |
Side effects should be implemented using a callback/pubsub system. This | |
allows for indpendent testing. | |
Functionality that is needed to boot the firmware: | |
* parameters - Keyword list of {param_atom, float} | |
Side affects that should be handled | |
* position reports | |
* end stop reports | |
* calibration reports | |
* busy reports | |
# State machine | |
The firmware starts in a `:transport_boot` state, moving to `:boot`. It then | |
loads all parameters writes all parameters, and goes to idle if all params | |
were loaded successfully. | |
State machine flows go as follows: | |
## Boot | |
:transport_boot | |
|> :boot | |
|> :no_config | |
|> :configuration | |
|> :idle | |
## Idle | |
:idle | |
|> :begin | |
|> :busy | |
|> :error | :invalid | :success | |
# Constraints and Exceptions | |
Commands will be queued as they received with some exceptions: | |
* if a command is currently executing (state is not `:idle`), | |
proceding commands will be queued in the order they are received. | |
* the `:emergency_lock` and `:emergency_unlock` commands go to the front | |
of the command queue and are started immediately. | |
* if a `report_emergency_lock` message is received at any point during a | |
commands execution, that command is considered an error. | |
(this does not apply to `:boot` state, since `:parameter_write` | |
is accepted while the firmware is locked.) | |
* all reports outside of control flow reports (:begin, :error, :invalid, | |
:success) will be discarded while in `:boot` state. This means while | |
boot, position updates, end stop updates etc are ignored. | |
# Transports | |
GCODES should be exchanged in the following format: | |
{tag, {command, args}} | |
* `tag` - binary integer. This is translated to the `Q` parameter. | |
* `command` - either a `RXX`, `FXX`, or `GXX` code. | |
* `args` - a list of arguments to be processed. | |
For example a report might look like: | |
{"123", {:report_some_information, [h: 10.00, u: 90.10]}} | |
and a command might look like: | |
{"555", {:fire_laser, [w: 100.00]}} | |
Numbers should be floats when possible. An Exeption to this is `:report_end_stops` | |
where there is only two values: `1` or `0`. | |
See the `GCODE` module for more information on available implemented GCODES. | |
a `Transport` should be a process that implements standard `GenServer` | |
behaviour. | |
Upon `init/1` the args passed in should be a Keyword list required to configure | |
the transport such as a serial device, etc. `args` will also contain a | |
`:handle_gcode` function that should be called everytime a GCODE is received. | |
Keyword.fetch!(args, :handle_gcode).({"999", {:report_software_version, ["Just a test!"]}}) | |
a transport should also implement a `handle_call` clause like: | |
def handle_call({"166", {:parameter_write, [some_param: 100.00]}}, _from, state) | |
and reply with `:ok | {:error, term()}` | |
# VCR | |
This server can save all the input and output gcodes to a text file for | |
further external analysis or playback later. | |
## Using VCR mode | |
The server can be started in VCR mode by doing: | |
FarmbotFirmware.start_link([transport: FarmbotFirmware.StubTransport, vcr_path: "/tmp/vcr.txt"], []) | |
or can be started at runtime: | |
FarmbotFirmware.enter_vcr_mode(firmware_server, "/tmp/vcr.txt") | |
in either case the VCR recording needs to be stopped: | |
FarmbotFirmware.exit_vcr_mode(firmware_server) | |
VCRs can later be played back: | |
FarmbotFirmware.VCR.playback!("/tmp/vcr.txt") | |
""" | |
use GenServer | |
require Logger | |
alias FarmbotFirmware, as: State | |
alias FarmbotFirmware.{GCODE, Command, Request} | |
@transport_init_error_retry_ms 5_000 | |
@type status :: | |
:transport_boot | |
| :boot | |
| :no_config | |
| :configuration | |
| :idle | |
| :emergency_lock | |
defstruct [ | |
:transport, | |
:transport_pid, | |
:transport_ref, | |
:transport_args, | |
:side_effects, | |
:status, | |
:tag, | |
:configuration_queue, | |
:command_queue, | |
:caller_pid, | |
:current, | |
:vcr_fd | |
] | |
@type state :: %State{ | |
transport: module(), | |
transport_pid: nil | pid(), | |
transport_ref: nil | reference(), | |
transport_args: Keyword.t(), | |
side_effects: nil | module(), | |
status: status(), | |
tag: GCODE.tag(), | |
configuration_queue: [{GCODE.kind(), GCODE.args()}], | |
command_queue: [{pid(), GCODE.t()}], | |
caller_pid: nil | pid, | |
current: nil | GCODE.t(), | |
vcr_fd: nil | File.io_device() | |
} | |
@doc """ | |
Command the firmware to do something. Takes a `{tag, {command, args}}` | |
GCODE. This command will be queued if there is already a command | |
executing. (this does not apply to `:emergency_lock` and `:emergency_unlock`) | |
## Response/Control Flow | |
When executed, `command` will block until one of the following respones | |
are received: | |
* `{:report_success, []}` -> `:ok` | |
* `{:report_invalid, []}` -> `{:error, :invalid_command}` | |
* `{:report_error, []}` -> `{:error, :firmware_error}` | |
* `{:report_emergency_lock, []}` -> {:error, :emergency_lock}` | |
If the firmware is in any of the following states: | |
* `:boot` | |
* `:no_config` | |
* `:configuration` | |
`command` will fail with `{:error, state}` | |
""" | |
defdelegate command(server \\ __MODULE__, code), to: Command | |
@doc """ | |
Request data from the firmware. | |
Valid requests are of kind: | |
:parameter_read | |
:status_read | |
:pin_read | |
:end_stops_read | |
:position_read | |
:software_version_read | |
Will return `{:ok, {tag, {:report_*, args}}}` on success | |
or `{:error, term()}` on error. | |
""" | |
defdelegate request(server \\ __MODULE__, code), to: Request | |
@doc """ | |
Close the transport, putting the Firmware State Machine back into | |
the `:transport_boot` state. | |
""" | |
def close_transport(server \\ __MODULE__) do | |
_ = command(server, {nil, {:command_emergency_lock, []}}) | |
GenServer.call(server, :close_transport) | |
end | |
@doc """ | |
Opens the transport, | |
""" | |
def open_transport(server \\ __MODULE__, module, args) do | |
GenServer.call(server, {:open_transport, module, args}) | |
end | |
@doc """ | |
Sets the Firmware server to record input and output GCODES | |
to a pair of text files. | |
""" | |
def enter_vcr_mode(server \\ __MODULE__, tape_path) do | |
GenServer.call(server, {:enter_vcr_mode, tape_path}) | |
end | |
@doc """ | |
Sets the Firmware server to stop recording input and output | |
GCODES. | |
""" | |
def exit_vcr_mode(server \\ __MODULE__) do | |
GenServer.cast(server, :exit_vcr_mode) | |
end | |
@doc """ | |
Starting the Firmware server requires at least: | |
* `:transport` - a module implementing the Transport GenServer behaviour. | |
See the `Transports` section of moduledoc. | |
Every other arg passed in will be passed directly to the `:transport` module's | |
`init/1` function. | |
""" | |
def start_link(args, opts \\ [name: __MODULE__]) do | |
GenServer.start_link(__MODULE__, args, opts) | |
end | |
def init(args) do | |
global = Application.get_env(:farmbot_firmware, __MODULE__, []) | |
args = Keyword.merge(args, global) | |
transport = Keyword.fetch!(args, :transport) | |
side_effects = Keyword.get(args, :side_effects) | |
vcr_fd = | |
case Keyword.get(args, :vcr_path) do | |
nil -> | |
nil | |
tape_path -> | |
{:ok, vcr_fd} = File.open(tape_path, [:binary, :append, :exclusive, :write]) | |
vcr_fd | |
end | |
# Add an anon function that transport implementations should call. | |
fw = self() | |
fun = fn {_, _} = code -> GenServer.cast(fw, code) end | |
transport_args = Keyword.put(args, :handle_gcode, fun) | |
state = %State{ | |
transport_pid: nil, | |
transport_ref: nil, | |
transport: transport, | |
transport_args: transport_args, | |
side_effects: side_effects, | |
status: :transport_boot, | |
command_queue: [], | |
configuration_queue: [], | |
vcr_fd: vcr_fd | |
} | |
send(self(), :timeout) | |
{:ok, state} | |
end | |
def terminate(reason, state) do | |
for {pid, _code} <- state.command_queue, do: send(pid, reason) | |
state.transport_pid && | |
Process.alive?(state.transport_pid) && | |
GenServer.stop(state.transport_pid) | |
end | |
# This will be the first message received right after `init/1` | |
# It should try to open a transport every `transport_init_error_retry_ms` | |
# until success. | |
# TODO(Connor) maybe make this timer back off over time. | |
def handle_info(:timeout, %{status: :transport_boot} = state) do | |
case GenServer.start_link(state.transport, state.transport_args) do | |
{:ok, pid} -> | |
ref = Process.monitor(pid) | |
Logger.debug( | |
"Firmware Transport #{state.transport} started. #{inspect(state.transport_args)}" | |
) | |
state = goto(%{state | transport_pid: pid, transport_ref: ref}, :boot) | |
{:noreply, state} | |
error -> | |
Logger.error("Error starting Firmware: #{inspect(error)}") | |
Process.send_after(self(), :timeout, @transport_init_error_retry_ms) | |
{:noreply, state} | |
end | |
end | |
# @spec handle_info(:timeout, state) :: {:noreply, state} | |
def handle_info( | |
:timeout, | |
%{command_queue: [{pid, {tag, {:command_emergency_lock, []} = code}} | _]} = state | |
) do | |
case GenServer.call(state.transport_pid, {tag, code}) do | |
:ok -> | |
new_state = %{state | tag: tag, current: code, command_queue: [], caller_pid: pid} | |
_ = side_effects(new_state, :handle_output_gcode, [{state.tag, code}]) | |
_ = vcr_write(state, :out, {state.tag, code}) | |
{:noreply, new_state} | |
error -> | |
{:stop, error, state} | |
end | |
end | |
def handle_info(:timeout, %{configuration_queue: [code | rest]} = state) do | |
Logger.debug("Starting next configuration code: #{inspect(code)}") | |
case GenServer.call(state.transport_pid, {state.tag, code}) do | |
:ok -> | |
new_state = %{state | current: code, configuration_queue: rest} | |
_ = side_effects(new_state, :handle_output_gcode, [{state.tag, code}]) | |
_ = vcr_write(state, :out, {state.tag, code}) | |
{:noreply, new_state} | |
error -> | |
{:stop, error, state} | |
end | |
end | |
def handle_info(:timeout, %{current: c} = state) when is_tuple(c) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, {:report_busy, []}}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
# Logger.debug "Got checkup message when current command still executing" | |
{:noreply, state} | |
end | |
def handle_info(:timeout, %{command_queue: [{pid, {tag, code}} | rest]} = state) do | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
case GenServer.call(state.transport_pid, {tag, code}) do | |
:ok -> | |
new_state = %{state | tag: tag, current: code, command_queue: rest, caller_pid: pid} | |
_ = side_effects(new_state, :handle_output_gcode, [{state.tag, code}]) | |
_ = vcr_write(state, :out, {state.tag, code}) | |
for {pid, _code} <- rest, do: send(pid, {state.tag, {:report_busy, []}}) | |
{:noreply, new_state} | |
error -> | |
{:stop, error, state} | |
end | |
end | |
def handle_info(:timeout, %{configuration_queue: []} = state) do | |
{:noreply, state} | |
end | |
# Closing the transport will purge the buffer of queued commands in both | |
# the `configuration_queue` and in the `command_queue`. | |
def handle_call(:close_transport, _from, %{status: s} = state) when s != :transport_boot do | |
true = Process.demonitor(state.transport_ref) | |
:ok = GenServer.stop(state.transport_pid, :normal) | |
state = | |
goto( | |
%{ | |
state | |
| transport_pid: nil, | |
transport_ref: nil, | |
status: :transport_boot, | |
command_queue: [], | |
configuration_queue: [] | |
}, | |
:transport_boot | |
) | |
{:reply, :ok, state} | |
end | |
def handle_call(:close_transport, _, %{status: s} = state) do | |
{:reply, {:error, s}, state} | |
end | |
def handle_call({:open_transport, module, args}, _from, %{status: s} = state) | |
when s == :transport_boot do | |
# Add an anon function that transport implementations should call. | |
fw = self() | |
fun = fn {_, _} = code -> GenServer.cast(fw, code) end | |
transport_args = Keyword.put(args, :handle_gcode, fun) | |
next_state = %{state | transport: module, transport_args: transport_args} | |
send(self(), :timeout) | |
{:reply, :ok, next_state} | |
end | |
def handle_call({:open_transport, _module, _args}, _from, %{status: s} = state) do | |
{:reply, {:error, s}, state} | |
end | |
def handle_call({:enter_vcr_mode, tape_path}, _from, state) do | |
with {:ok, vcr_fd} <- File.open(tape_path, [:binary, :append, :exclusive, :write]) do | |
{:reply, :ok, %{state | vcr_fd: vcr_fd}} | |
else | |
error -> | |
{:reply, error, state} | |
end | |
end | |
def handle_call({_tag, _code} = gcode, from, state) do | |
handle_command(gcode, from, state) | |
end | |
@doc false | |
@spec handle_command(GCODE.t(), GenServer.from(), state()) :: {:reply, term(), state()} | |
# EmergencyLock should be ran immediately | |
def handle_command({tag, {:command_emergency_lock, []}} = code, {pid, _ref}, state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, {:report_emergency_lock, []}}) | |
for {pid, _code} <- state.command_queue, | |
do: send(pid, {state.tag, {:report_emergency_lock, []}}) | |
send(self(), :timeout) | |
{:reply, {:ok, tag}, %{state | command_queue: [{pid, code}], configuration_queue: []}} | |
end | |
# EmergencyUnLock should be ran immediately | |
def handle_command({tag, {:command_emergency_unlock, []}} = code, {pid, _ref}, state) do | |
send(self(), :timeout) | |
{:reply, {:ok, tag}, %{state | command_queue: [{pid, code}], configuration_queue: []}} | |
end | |
# If not in an acceptable state, return an error immediately. | |
def handle_command(_, _, %{status: s} = state) | |
when s in [:transport_boot, :boot, :no_config, :configuration] do | |
{:reply, {:error, s}, state} | |
end | |
def handle_command({tag, {_, _}} = code, {pid, _ref}, state) do | |
new_state = %{state | command_queue: state.command_queue ++ [{pid, code}]} | |
case {new_state.status, state.current} do | |
{:idle, nil} -> | |
send(self(), :timeout) | |
{:reply, {:ok, tag}, new_state} | |
# Don't do any flow control if state is emergency_lock. | |
# This allows a transport to decide | |
# if a command should be blocked or not. | |
{:emergency_lock, _} -> | |
send(self(), :timeout) | |
{:reply, {:ok, tag}, new_state} | |
_unknown -> | |
{:reply, {:ok, tag}, new_state} | |
end | |
end | |
def handle_cast(:exit_vcr_mode, state) do | |
state.vcr_fd && File.close(state.vcr_fd) | |
{:noreply, %{state | vcr_fd: nil}} | |
end | |
# Extracts tag | |
def handle_cast({tag, {_, _} = code}, state) do | |
_ = side_effects(state, :handle_input_gcode, [{tag, code}]) | |
_ = vcr_write(state, :in, {tag, code}) | |
handle_report(code, %{state | tag: tag}) | |
end | |
@doc false | |
@spec handle_report({GCODE.report_kind(), GCODE.args()}, state) :: {:noreply, state()} | |
def handle_report({:report_emergency_lock, []} = code, state) do | |
Logger.info("Emergency lock") | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, code) | |
send(self(), :timeout) | |
{:noreply, goto(%{state | current: nil, caller_pid: nil}, :emergency_lock)} | |
end | |
# "ARDUINO STARTUP COMPLETE" => goto(:boot, :no_config) | |
def handle_report( | |
{:unknown, [_, "ARDUINO", "STARTUP", "COMPLETE"]}, | |
%{status: :boot} = state | |
) do | |
Logger.info("ARDUINO STARTUP COMPLETE (text) transport=#{state.transport}") | |
handle_report({:report_no_config, []}, state) | |
end | |
def handle_report( | |
{:report_idle, []}, | |
%{status: :boot} = state | |
) do | |
Logger.info("ARDUINO STARTUP COMPLETE (idle) transport=#{state.transport}") | |
handle_report({:report_no_config, []}, state) | |
end | |
def handle_report( | |
{:report_debug_message, ["ARDUINO STARTUP COMPLETE"]}, | |
%{status: :boot} = state | |
) do | |
Logger.info("ARDUINO STARTUP COMPLETE (r99) transport=#{state.transport}") | |
handle_report({:report_no_config, []}, state) | |
end | |
# report_no_config => goto(_, :no_config) | |
def handle_report({:report_no_config, []}, %{status: _} = state) do | |
Logger.warn(":report_no_config received") | |
tag = state.tag || "0" | |
loaded_params = side_effects(state, :load_params, []) || [] | |
param_commands = | |
Enum.reduce(loaded_params, [], fn {param, val}, acc -> | |
if val, do: acc ++ [{:parameter_write, [{param, val}]}], else: acc | |
end) | |
to_process = | |
[{:software_version_read, []} | param_commands] ++ | |
[ | |
{:parameter_write, [{:param_use_eeprom, 0.0}]}, | |
{:parameter_write, [{:param_config_ok, 1.0}]}, | |
{:parameter_read_all, []} | |
] | |
to_process = | |
if loaded_params[:movement_home_at_boot_z] == 1, | |
do: to_process ++ [{:command_movement_find_home, [:z]}], | |
else: to_process | |
to_process = | |
if loaded_params[:movement_home_at_boot_y] == 1, | |
do: to_process ++ [{:command_movement_find_home, [:y]}], | |
else: to_process | |
to_process = | |
if loaded_params[:movement_home_at_boot_x] == 1, | |
do: to_process ++ [{:command_movement_find_home, [:x]}], | |
else: to_process | |
send(self(), :timeout) | |
{:noreply, goto(%{state | tag: tag, configuration_queue: to_process}, :configuration)} | |
end | |
def handle_report({:report_debug_message, msg}, state) do | |
side_effects(state, :handle_debug_message, [msg]) | |
{:noreply, state} | |
end | |
def handle_report(report, %{status: :boot} = state) do | |
Logger.debug(["still in state: :boot ", inspect(report)]) | |
{:noreply, state} | |
end | |
# report_idle => goto(_, :idle) | |
def handle_report({:report_idle, []}, %{status: _} = state) do | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
side_effects(state, :handle_busy, [false]) | |
side_effects(state, :handle_idle, [true]) | |
send(self(), :timeout) | |
{:noreply, goto(%{state | caller_pid: nil, current: nil}, :idle)} | |
end | |
def handle_report({:report_begin, []} = code, state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
{:noreply, goto(state, :begin)} | |
end | |
def handle_report({:report_success, []} = code, state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
new_state = %{state | current: nil, caller_pid: nil} | |
side_effects(state, :handle_busy, [false]) | |
send(self(), :timeout) | |
{:noreply, goto(new_state, :idle)} | |
end | |
def handle_report({:report_busy, []} = code, state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
side_effects(state, :handle_busy, [true]) | |
{:noreply, goto(state, :busy)} | |
end | |
def handle_report({:report_error, []} = code, %{status: :configuration} = state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
side_effects(state, :handle_busy, [false]) | |
{:stop, {:error, state.current}, state} | |
end | |
def handle_report({:report_error, []} = code, state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
side_effects(state, :handle_busy, [false]) | |
send(self(), :timeout) | |
{:noreply, %{state | caller_pid: nil, current: nil}} | |
end | |
def handle_report({:report_invalid, []} = code, %{status: :configuration} = state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
{:stop, {:error, state.current}, state} | |
end | |
def handle_report({:report_invalid, []} = code, state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
send(self(), :timeout) | |
{:noreply, %{state | caller_pid: nil, current: nil}} | |
end | |
def handle_report({:report_retry, []} = code, %{status: :configuration} = state) do | |
Logger.warn("Retrying configuration command: #{inspect(code)}") | |
{:noreply, state} | |
end | |
def handle_report({:report_retry, []} = code, state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
{:noreply, state} | |
end | |
def handle_report({:report_parameter_value, param} = code, state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
side_effects(state, :handle_parameter_value, [param]) | |
{:noreply, state} | |
end | |
def handle_report({:report_calibration_parameter_value, param} = _code, state) do | |
to_process = [{:parameter_write, param}] | |
side_effects(state, :handle_parameter_value, [param]) | |
side_effects(state, :handle_parameter_calibration_value, [param]) | |
send(self(), :timeout) | |
{:noreply, goto(%{state | tag: state.tag, configuration_queue: to_process}, :configuration)} | |
end | |
# report_parameters_complete => goto(:configuration, :idle) | |
def handle_report({:report_parameters_complete, []}, %{status: status} = state) | |
when status in [:begin, :configuration] do | |
{:noreply, goto(state, :idle)} | |
end | |
def handle_report(_, %{status: :no_config} = state) do | |
{:noreply, state} | |
end | |
def handle_report({:report_position, position} = code, state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
side_effects(state, :handle_position, [position]) | |
{:noreply, state} | |
end | |
def handle_report({:report_axis_state, axis_state} = code, state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
side_effects(state, :handle_axis_state, [axis_state]) | |
{:noreply, state} | |
end | |
def handle_report({:report_calibration_state, calibration_state} = code, state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
side_effects(state, :handle_calibration_state, [calibration_state]) | |
{:noreply, state} | |
end | |
def handle_report({:report_home_complete, axis} = code, state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
side_effects(state, :handle_home_complete, axis) | |
{:noreply, state} | |
end | |
def handle_report({:report_position_change, position} = code, state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
side_effects(state, :handle_position_change, [position]) | |
{:noreply, state} | |
end | |
def handle_report({:report_encoders_scaled, encoders} = code, state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
side_effects(state, :handle_encoders_scaled, [encoders]) | |
{:noreply, state} | |
end | |
def handle_report({:report_encoders_raw, encoders} = code, state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
side_effects(state, :handle_encoders_raw, [encoders]) | |
{:noreply, state} | |
end | |
def handle_report({:report_end_stops, end_stops} = code, state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
side_effects(state, :handle_end_stops, [end_stops]) | |
{:noreply, state} | |
end | |
def handle_report({:report_pin_value, value} = code, state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
side_effects(state, :handle_pin_value, [value]) | |
{:noreply, state} | |
end | |
def handle_report({:report_software_version, version} = code, state) do | |
if state.caller_pid, do: send(state.caller_pid, {state.tag, code}) | |
for {pid, _code} <- state.command_queue, do: send(pid, {state.tag, {:report_busy, []}}) | |
side_effects(state, :handle_software_version, [version]) | |
{:noreply, state} | |
end | |
# NOOP | |
def handle_report({:report_echo, _}, state), do: {:noreply, state} | |
def handle_report({_kind, _args} = code, state) do | |
Logger.warn("unknown code for #{state.status}: #{inspect(code)}") | |
{:noreply, state} | |
end | |
@spec goto(state(), status()) :: state() | |
defp goto(%{status: old} = state, new) do | |
new_state = %{state | status: new} | |
cond do | |
old != new && new == :emergency_lock -> | |
side_effects(new_state, :handle_emergency_lock, []) | |
old != new && old == :emergency_lock -> | |
side_effects(new_state, :handle_emergency_unlock, []) | |
# Boot up emergency unlock | |
old == :boot && new != :emergency_lock -> | |
side_effects(new_state, :handle_emergency_unlock, []) | |
# start of a command. | |
old == :idle && new == :begin -> | |
:ok | |
# command processing | |
old == :begin && new == :busy -> | |
:ok | |
# command completion | |
old == :begin && new == :idle -> | |
:ok | |
# command completion | |
old == :busy && new == :idle -> | |
:ok | |
old == new -> | |
:ok | |
true -> | |
Logger.debug("firmware state change: #{old} => #{new}") | |
end | |
new_state | |
end | |
@spec side_effects(state, atom, GCODE.args()) :: any() | |
defp side_effects(%{side_effects: nil}, _function, _args), do: nil | |
defp side_effects(%{side_effects: m}, function, args), do: apply(m, function, args) | |
@spec vcr_write(state, :in | :out, GCODE.t()) :: :ok | |
defp vcr_write(%{vcr_fd: nil}, _direction, _code), do: :ok | |
defp vcr_write(state, :in, code), do: vcr_write(state, "<", code) | |
defp vcr_write(state, :out, code), do: vcr_write(state, "\n>", code) | |
defp vcr_write(state, direction, code) do | |
data = GCODE.encode(code) | |
time = :os.system_time(:second) | |
current_data = | |
if state.current do | |
GCODE.encode({state.tag, state.current}) | |
else | |
"nil" | |
end | |
state_data = "#{state.status} | #{current_data} | #{inspect(state.caller_pid)}" | |
IO.write(state.vcr_fd, direction <> " #{time} " <> data <> " state=" <> state_data <> "\n") | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule FarmbotFirmware.Request do | |
@moduledoc false | |
alias FarmbotFirmware | |
alias FarmbotFirmware.GCODE | |
@spec request(GenServer.server(), GCODE.t()) :: | |
{:ok, GCODE.t()} | {:error, :invalid_command | :firmware_error | FarmbotFirmware.status()} | |
def request(firmware_server \\ FarmbotFirmware, code) | |
def request(firmware_server, {_tag, {kind, _}} = code) do | |
if kind not in [ | |
:parameter_read, | |
:status_read, | |
:pin_read, | |
:end_stops_read, | |
:position_read, | |
:software_version_read | |
] do | |
raise ArgumentError, "#{kind} is not a valid request." | |
end | |
case GenServer.call(firmware_server, code, :infinity) do | |
{:ok, tag} -> | |
wait_for_request_result(tag, code) | |
{:error, status} -> | |
{:error, status} | |
end | |
end | |
def request(firmware_server, {_, _} = code) do | |
request(firmware_server, {to_string(:rand.uniform(100)), code}) | |
end | |
# This is a bit weird but let me explain: | |
# if this function `receive`s | |
# * report_error | |
# * report_invalid | |
# * report_emergency_lock | |
# it needs to return an error. | |
# If this function `receive`s | |
# * report_success | |
# when no valid data has been collected from `wait_for_request_result_process` | |
# it needs to return an error. | |
# If this function `receive`s | |
# * report_success | |
# when valid data has been collected from `wait_for_request_result_process` | |
# it will return that data. | |
# If this function returns no data for 5 seconds, it needs to error. | |
defp wait_for_request_result(tag, code, result \\ nil) do | |
receive do | |
{tag, {:report_begin, []}} -> | |
wait_for_request_result(tag, code, result) | |
{tag, {:report_busy, []}} -> | |
wait_for_request_result(tag, code, result) | |
{tag, {:report_success, []}} -> | |
if result, | |
do: {:ok, {tag, result}}, | |
else: wait_for_request_result(tag, code, result) | |
{_, {:report_error, []}} -> | |
{:error, :firmware_error} | |
{_, {:report_invalid, []}} -> | |
{:error, :invalid_command} | |
{_, {:report_emergency_lock, []}} -> | |
{:error, :emergency_lock} | |
{:error, reason} -> | |
{:error, reason} | |
{tag, report} -> | |
wait_for_request_result_process(report, tag, code, result) | |
after | |
10_000 -> | |
if result, | |
do: {:ok, {tag, result}}, | |
else: {:error, "timeout waiting for request to complete"} | |
end | |
end | |
# {:parameter_read, [param]} => {:report_parameter_value, [{param, val}]} | |
defp wait_for_request_result_process( | |
{:report_parameter_value, _} = report, | |
tag, | |
{_, {:parameter_read, _}} = code, | |
_ | |
) do | |
wait_for_request_result(tag, code, report) | |
end | |
# {:status_read, [status]} => {:report_status_value, [{status, value}]} | |
defp wait_for_request_result_process( | |
{:report_status_value, _} = report, | |
tag, | |
{_, {:status_read, _}} = code, | |
_ | |
) do | |
wait_for_request_result(tag, code, report) | |
end | |
# {:pin_read, [pin]} => {:report_pin_value, [{pin, value}]} | |
defp wait_for_request_result_process( | |
{:report_pin_value, _} = report, | |
tag, | |
{_, {:pin_read, _}} = code, | |
_ | |
) do | |
wait_for_request_result(tag, code, report) | |
end | |
# {:end_stops_read, []} => {:position_end_stops, end_stops} | |
defp wait_for_request_result_process( | |
{:report_end_stops, _} = report, | |
tag, | |
{_, {:end_stops_read, []}} = code, | |
_ | |
) do | |
wait_for_request_result(tag, code, report) | |
end | |
# {:position_read, []} => {:position_report, [x: x, y: y, z: z]} | |
defp wait_for_request_result_process( | |
{:report_position, _} = report, | |
tag, | |
{_, {:position_read, []}} = code, | |
_ | |
) do | |
wait_for_request_result(tag, code, report) | |
end | |
# {:software_version_read, []} => {:report_software_version, [version]} | |
defp wait_for_request_result_process( | |
{:report_software_version, _} = report, | |
tag, | |
{_, {:software_version_read, _}} = code, | |
_ | |
) do | |
wait_for_request_result(tag, code, report) | |
end | |
defp wait_for_request_result_process(_report, tag, code, result) do | |
wait_for_request_result(tag, code, result) | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment