422 lines
12 KiB
Elixir
422 lines
12 KiB
Elixir
defmodule Farmbot.CeleryScript.RunTime do
|
|
@moduledoc """
|
|
Manages many FarmProcs
|
|
"""
|
|
alias Farmbot.CeleryScript.RunTime
|
|
use GenServer
|
|
use Bitwise, only: [bsl: 2]
|
|
alias RunTime.{FarmProc, ProcStorage}
|
|
import Farmbot.CeleryScript.Utils
|
|
alias Farmbot.CeleryScript.AST
|
|
alias AST.Heap
|
|
require Logger
|
|
|
|
# Frequency of vm ticks.
|
|
@tick_timeout 20
|
|
|
|
@kinds_that_need_fw [
|
|
:config_update,
|
|
:_if,
|
|
:write_pin,
|
|
:read_pin,
|
|
:move_absolute,
|
|
:set_servo_angle,
|
|
:move_relative,
|
|
:home,
|
|
:find_home,
|
|
:toggle_pin,
|
|
:zero,
|
|
:calibrate,
|
|
|
|
]
|
|
|
|
@kinds_aloud_while_locked [
|
|
:rpc_request,
|
|
:sequence,
|
|
:check_updates,
|
|
:config_update,
|
|
:uninstall_farmware,
|
|
:update_farmware,
|
|
:rpc_request,
|
|
:rpc_ok,
|
|
:rpc_error,
|
|
:install,
|
|
:read_status,
|
|
:sync,
|
|
:power_off,
|
|
:reboot,
|
|
:factory_reset,
|
|
:set_user_env,
|
|
:install_first_party_farmware,
|
|
:change_ownership,
|
|
:dump_info,
|
|
:_if,
|
|
:send_message,
|
|
:sequence,
|
|
:wait,
|
|
:execute,
|
|
:execute_script,
|
|
:emergency_lock,
|
|
:emergency_unlock
|
|
]
|
|
|
|
defstruct [
|
|
:proc_storage,
|
|
:hyper_state,
|
|
:fw_proc,
|
|
:process_io_layer,
|
|
:hyper_io_layer,
|
|
:tick_timer,
|
|
:monitors
|
|
]
|
|
|
|
defmodule Monitor do
|
|
defstruct [:pid, :ref, :index]
|
|
def new(pid, index) do
|
|
ref = Process.monitor(pid)
|
|
%Monitor{ref: ref, pid: pid, index: index}
|
|
end
|
|
end
|
|
|
|
@opaque job_id :: CircularList.index()
|
|
|
|
@doc "Execute an rpc_request, this is sync."
|
|
def rpc_request(pid \\ __MODULE__, %{} = map, fun)
|
|
when is_function(fun) do
|
|
%AST{} = ast = AST.decode(map)
|
|
label = ast.args[:label] || raise(ArgumentError)
|
|
job = queue(pid, map, -1)
|
|
|
|
if job do
|
|
proc = await(pid, job)
|
|
|
|
case FarmProc.get_status(proc) do
|
|
:done ->
|
|
results = ast(:rpc_ok, %{label: label}, [])
|
|
apply_callback(fun, [results])
|
|
|
|
:crashed ->
|
|
message = FarmProc.get_crash_reason(proc)
|
|
explanation = ast(:explanation, %{message: message})
|
|
results = ast(:rpc_error, %{label: label}, [explanation])
|
|
apply_callback(fun, [results])
|
|
end
|
|
else
|
|
# if no job is returned, this was a hyper function, which
|
|
# can never fail.
|
|
results = ast(:rpc_ok, %{label: label}, [])
|
|
apply_callback(fun, [results])
|
|
end
|
|
end
|
|
|
|
@doc "Execute a sequence. This is async."
|
|
def sequence(pid \\ __MODULE__, %{} = map, id, fun) when is_function(fun) do
|
|
job = queue(pid, map, id)
|
|
|
|
spawn(fn ->
|
|
proc = await(pid, job)
|
|
|
|
case FarmProc.get_status(proc) do
|
|
:done ->
|
|
apply_callback(fun, [:ok])
|
|
|
|
:crashed ->
|
|
apply_callback(fun, [{:error, FarmProc.get_crash_reason(proc)}])
|
|
end
|
|
end)
|
|
end
|
|
|
|
# Queues some data for execution.
|
|
# If kind == :emergency_lock or :emergency_unlock
|
|
# (or this is an rpc request with the first item being one of those.)
|
|
# this ast will immediately execute the `hyper_io_layer` function.
|
|
@spec queue(GenServer.server(), map, integer) :: job_id | nil
|
|
defp queue(pid, %{} = map, page_id) when is_integer(page_id) do
|
|
case AST.decode(map) do
|
|
%AST{kind: :rpc_request, body: [%AST{kind: :emergency_lock}]} ->
|
|
:emergency_lock = GenServer.call(pid, :emergency_lock)
|
|
nil
|
|
|
|
%AST{kind: :rpc_request, body: [%AST{kind: :emergency_unlock}]} ->
|
|
:emergency_unlock = GenServer.call(pid, :emergency_unlock)
|
|
nil
|
|
|
|
# An rpc with an empty list doesn't need to be queued.
|
|
%AST{kind: :rpc_request, body: []} ->
|
|
nil
|
|
|
|
%AST{} = ast ->
|
|
%Heap{} = heap = AST.slice(ast)
|
|
%Address{} = page = addr(page_id)
|
|
|
|
case GenServer.call(pid, {:queue, heap, page}) do
|
|
{:error, :busy} -> queue(pid, map, page_id)
|
|
job -> job
|
|
end
|
|
end
|
|
end
|
|
|
|
# Polls the GenServer until it returns a FarmProc with a stopped status
|
|
@spec await(GenServer.server(), job_id) :: FarmProc.t()
|
|
defp await(pid, job_id) do
|
|
case GenServer.call(pid, {:lookup, job_id}) do
|
|
{:error, :busy} ->
|
|
await(pid, job_id)
|
|
|
|
%FarmProc{} = proc ->
|
|
case FarmProc.get_status(proc) do
|
|
status when status in [:ok, :waiting] ->
|
|
Process.sleep(@tick_timeout * 2)
|
|
await(pid, job_id)
|
|
|
|
_ ->
|
|
proc
|
|
end
|
|
|
|
_ ->
|
|
raise(ArgumentError, "no job by that identifier")
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Start a CSVM monitor.
|
|
|
|
## Required params:
|
|
* `process_io_layer` ->
|
|
function that takes an AST whenever a FarmProc needs IO operations.
|
|
* `hyper_io_layer`
|
|
function that takes one of the hyper calls
|
|
"""
|
|
@spec start_link(Keyword.t(), GenServer.name()) :: GenServer.server()
|
|
def start_link(args, name \\ __MODULE__) do
|
|
GenServer.start_link(__MODULE__, Keyword.put(args, :name, name), name: name)
|
|
end
|
|
|
|
def init(args) do
|
|
timer = start_tick(self())
|
|
storage = ProcStorage.new(Keyword.fetch!(args, :name))
|
|
io_fun = Keyword.fetch!(args, :process_io_layer)
|
|
hyper_fun = Keyword.fetch!(args, :hyper_io_layer)
|
|
unless is_function(io_fun), do: raise(ArgumentError)
|
|
unless is_function(hyper_fun), do: raise(ArgumentError)
|
|
|
|
{:ok,
|
|
%RunTime{
|
|
monitors: %{},
|
|
process_io_layer: io_fun,
|
|
hyper_io_layer: hyper_fun,
|
|
tick_timer: timer,
|
|
proc_storage: storage
|
|
}}
|
|
end
|
|
|
|
def handle_call(:emergency_lock, _from, %RunTime{} = state) do
|
|
apply_callback(state.hyper_io_layer, [:emergency_lock])
|
|
{:reply, :emergency_lock, %{state | hyper_state: :emergency_lock}}
|
|
end
|
|
|
|
def handle_call(:emergency_unlock, _from, %RunTime{} = state) do
|
|
apply_callback(state.hyper_io_layer, [:emergency_unlock])
|
|
{:reply, :emergency_unlock, %{state | hyper_state: nil}}
|
|
end
|
|
|
|
def handle_call(_, _from, {:busy, state}) do
|
|
{:reply, {:error, :busy}, {:busy, state}}
|
|
end
|
|
|
|
def handle_call({:queue, %Heap{} = h, %Address{} = p}, {caller, _ref}, %RunTime{} = state) do
|
|
%FarmProc{} = new_proc = FarmProc.new(state.process_io_layer, p, h)
|
|
index = ProcStorage.insert(state.proc_storage, new_proc)
|
|
mon = Monitor.new(caller, index)
|
|
{:reply, index, %{state | monitors: Map.put(state.monitors, caller, mon)}}
|
|
end
|
|
|
|
def handle_call({:lookup, id}, _from, %RunTime{} = state) do
|
|
cleanup = fn proc, state ->
|
|
ProcStorage.delete(state.proc_storage, id)
|
|
|
|
state = case Enum.find(state.monitors, fn({_, %{index: index}}) -> index == id end) do
|
|
{pid, mon} ->
|
|
Process.demonitor(mon.ref)
|
|
%{state | monitors: Map.delete(state.monitors, pid)}
|
|
_ -> state
|
|
end
|
|
|
|
state = if proc.ref == state.fw_proc,
|
|
do: %{state | fw_proc: nil},
|
|
else: state
|
|
|
|
{:reply, proc, state}
|
|
end
|
|
|
|
# Looks up a FarmProc, causes a few different side affects.
|
|
# if the status is :done or :crashed, delete it from ProcStorage.
|
|
# if deleted, and this proc owns the firmware,
|
|
# delete it from there also.
|
|
case ProcStorage.lookup(state.proc_storage, id) do
|
|
%FarmProc{status: :crashed} = proc ->
|
|
cleanup.(proc, state)
|
|
|
|
%FarmProc{status: :done} = proc ->
|
|
cleanup.(proc, state)
|
|
|
|
reply ->
|
|
{:reply, reply, state}
|
|
end
|
|
end
|
|
|
|
def handle_info({:DOWN, _ref, :process, pid, _}, %RunTime{} = state) do
|
|
cleanup = fn proc, id, state ->
|
|
ProcStorage.delete(state.proc_storage, id)
|
|
|
|
if proc.ref == state.fw_proc,
|
|
do: %{state | fw_proc: nil},
|
|
else: state
|
|
end
|
|
|
|
mon = state.monitors[pid]
|
|
state = if mon do
|
|
case ProcStorage.lookup(state.proc_storage, mon.index) do
|
|
%FarmProc{status: :crashed} = proc -> cleanup.(proc, mon.index, state)
|
|
%FarmProc{status: :done} = proc -> cleanup.(proc, mon.index, state)
|
|
_ -> state
|
|
end
|
|
else
|
|
state
|
|
end
|
|
|
|
{:noreply, %{state | monitors: Map.delete(state.monitors, pid)}}
|
|
end
|
|
|
|
def handle_info({:DOWN, _, :process, _, _} = down, {:busy, _} = busy) do
|
|
Process.send_after(self(), down, @tick_timeout)
|
|
{:noreply, busy}
|
|
end
|
|
|
|
def handle_info(:tick, %RunTime{} = state) do
|
|
pid = self()
|
|
# Calls `do_tick/3` with either
|
|
# * a FarmProc that needs updating
|
|
# * a :noop atom
|
|
# state is set to {:busy, old_state}
|
|
# until `do_step` calls
|
|
# send(pid, %RunTime{})
|
|
ProcStorage.update(state.proc_storage, &do_step(&1, pid, state))
|
|
{:noreply, {:busy, state}}
|
|
end
|
|
|
|
# make sure to update the timer _AFTER_ we tick.
|
|
# This message comes from the do_step/3 function that gets called
|
|
# When updating a FarmProc.
|
|
def handle_info(%RunTime{} = state, {:busy, _old}) do
|
|
new_timer = start_tick(self())
|
|
{:noreply, %RunTime{state | tick_timer: new_timer}}
|
|
end
|
|
|
|
defp start_tick(pid, timeout \\ @tick_timeout),
|
|
do: Process.send_after(pid, :tick, timeout)
|
|
|
|
@doc false
|
|
# If there are no procs
|
|
def do_step(:noop, pid, state), do: send(pid, state)
|
|
|
|
# If the proc is crashed or done, don't step.
|
|
def do_step(%FarmProc{status: :crashed} = farm_proc, pid, state) do
|
|
send(pid, state)
|
|
farm_proc
|
|
end
|
|
|
|
def do_step(%FarmProc{status: :done} = farm_proc, pid, state) do
|
|
send(pid, state)
|
|
farm_proc
|
|
end
|
|
|
|
# If nothing currently owns the firmware,
|
|
# Check kind needs fw,
|
|
# Check kind is aloud while the bot is locked,
|
|
# Check if bot is unlocked
|
|
# If kind needs fw, update state.
|
|
def do_step(%FarmProc{} = farm_proc, pid, %{fw_proc: nil} = state) do
|
|
pc_ptr = FarmProc.get_pc_ptr(farm_proc)
|
|
kind = FarmProc.get_kind(farm_proc, pc_ptr)
|
|
b0 = (kind in @kinds_aloud_while_locked) |> bit()
|
|
b1 = (kind in @kinds_that_need_fw) |> bit()
|
|
b2 = true |> bit()
|
|
b3 = (state.hyper_state == :emergency_lock) |> bit()
|
|
bits = bsl(b0, 3) + bsl(b1, 2) + bsl(b2, 1) + b3
|
|
|
|
if should_step(bits) do
|
|
# Update state if this kind needs fw.
|
|
if bool(b1),
|
|
do: send(pid, %{state | fw_proc: farm_proc.ref}),
|
|
else: send(pid, state)
|
|
|
|
actual_step(farm_proc)
|
|
else
|
|
send(pid, state)
|
|
farm_proc
|
|
end
|
|
end
|
|
|
|
def do_step(%FarmProc{} = farm_proc, pid, state) do
|
|
pc_ptr = FarmProc.get_pc_ptr(farm_proc)
|
|
kind = FarmProc.get_kind(farm_proc, pc_ptr)
|
|
b0 = (kind in @kinds_aloud_while_locked) |> bit()
|
|
b1 = (kind in @kinds_that_need_fw) |> bit()
|
|
b2 = (farm_proc.ref == state.fw_proc) |> bit()
|
|
b3 = (state.hyper_state == :emergency_lock) |> bit()
|
|
bits = bsl(b0, 3) + bsl(b1, 2) + bsl(b2, 1) + b3
|
|
send(pid, state)
|
|
|
|
if should_step(bits),
|
|
do: actual_step(farm_proc),
|
|
else: farm_proc
|
|
end
|
|
|
|
defp should_step(0b0000), do: true
|
|
defp should_step(0b0001), do: false
|
|
defp should_step(0b0010), do: true
|
|
defp should_step(0b0011), do: false
|
|
defp should_step(0b0100), do: false
|
|
defp should_step(0b0101), do: false
|
|
defp should_step(0b0110), do: true
|
|
defp should_step(0b0111), do: false
|
|
defp should_step(0b1000), do: true
|
|
defp should_step(0b1001), do: true
|
|
defp should_step(0b1010), do: true
|
|
defp should_step(0b1011), do: true
|
|
defp should_step(0b1100), do: false
|
|
defp should_step(0b1101), do: false
|
|
defp should_step(0b1110), do: true
|
|
defp should_step(0b1111), do: true
|
|
|
|
defp bit(true), do: 1
|
|
defp bit(false), do: 0
|
|
defp bool(1), do: true
|
|
defp bool(0), do: false
|
|
|
|
@spec actual_step(FarmProc.t()) :: FarmProc.t()
|
|
defp actual_step(farm_proc) do
|
|
try do
|
|
FarmProc.step(farm_proc)
|
|
rescue
|
|
ex in FarmProc.Error ->
|
|
ex.farm_proc
|
|
|
|
ex ->
|
|
farm_proc
|
|
|> FarmProc.set_status(:crashed)
|
|
|> FarmProc.set_crash_reason(Exception.message(ex))
|
|
end
|
|
end
|
|
|
|
defp apply_callback(fun, results) when is_function(fun) do
|
|
try do
|
|
_ = apply(fun, results)
|
|
rescue
|
|
ex ->
|
|
Logger.error("Error executing farmbot_celery_script callback: #{Exception.message(ex)}")
|
|
end
|
|
end
|
|
end
|