From 7e1ceaf7be212352c7e2e6594e489c2afcecc0d0 Mon Sep 17 00:00:00 2001 From: Connor Rigby Date: Mon, 19 Aug 2019 17:23:00 -0700 Subject: [PATCH] Implement new AST: `assert`. This is a new AST that will allow executing a simple expression and conditionally pass/fail and cleanup when it completes. --- .gitignore | 1 + docs/celery_script/if_expressions.md | 74 +++++++++ .../lib/farmbot_celery_script/compiler.ex | 44 +++++- .../lib/farmbot_celery_script/scheduler.ex | 2 +- .../lib/farmbot_celery_script/sys_calls.ex | 14 ++ .../farmbot_celery_script/sys_calls/stubs.ex | 3 + farmbot_os/lib/farmbot_os/lua.ex | 56 +++++++ .../lib/farmbot_os/lua/celery_script.ex | 147 ++++++++++++++++++ farmbot_os/lib/farmbot_os/sys_calls.ex | 5 + farmbot_os/mix.exs | 1 + farmbot_os/mix.lock | 2 + test/support/celery_script/test_sys_calls.ex | 5 + 12 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 docs/celery_script/if_expressions.md create mode 100644 farmbot_os/lib/farmbot_os/lua.ex create mode 100644 farmbot_os/lib/farmbot_os/lua/celery_script.ex diff --git a/.gitignore b/.gitignore index ed555aec..4b850681 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,4 @@ nerves-hub *.pem *.db *.db-journal +*.lua diff --git a/docs/celery_script/if_expressions.md b/docs/celery_script/if_expressions.md new file mode 100644 index 00000000..9642ac8e --- /dev/null +++ b/docs/celery_script/if_expressions.md @@ -0,0 +1,74 @@ +# CeleryScript IF `expression` field. + +The CeleryScript `if` block takes a possible left hand side value of +`expression` which allows an arbitrary string to be evaluated. This +expression is evaluated against a lua 5.2 interpreter. + +## Lua API +The following functions are available for usage along with [Lua's +standard library](https://www.lua.org/manual/5.2/). + +```lua +-- Comments are ignored by the interpreter + +-- help(function_name) +-- Returns docs for a function + +print(help("send_message")); +print(help("get_position")); + +-- get_position() +-- Returns a table containing the current position data + +position = get_position(); +if position.x <= 20.55 then + return true; +else + print("current position: (", position.x, ",", position.y, "," position.z, ")"); + return false; +end + +-- get_pins() +-- Returns a table containing current pin data + +pins = get_pins(); +if pins[9] == 1.0 then + return true; +end + +-- send_message(type, message, channels) +-- Sends a message to farmbot's logger + +send_message("info", "hello, world", ["toast"]) +``` + +## Expression contract +Expressions are expected to be evaluated in a certain way. The evaluation will fail +if this contract is not met. An expression should return one of the following values: +* `true` +* `false` +* `("error", "string reason signaling an error happened")` + +### Examples + +Check if the x position is within a range of 5 and 10 + +```lua +position = get_position(); +return position.x >= 5 and position.x <= 10; +``` + +Check is a pin is a toggled, with error checking + +```lua +-- All farmbot functions will return a tuple containing an error +-- if something bad happens + +position, positionErr = get_position(); +pins, pinErr = get_pins(); +if positionErr or pinErr then + return "error", positionErr or pinErr; +else + return pins[9] == 1.0 +end +``` \ No newline at end of file diff --git a/farmbot_celery_script/lib/farmbot_celery_script/compiler.ex b/farmbot_celery_script/lib/farmbot_celery_script/compiler.ex index a033373d..8ac48059 100644 --- a/farmbot_celery_script/lib/farmbot_celery_script/compiler.ex +++ b/farmbot_celery_script/lib/farmbot_celery_script/compiler.ex @@ -166,8 +166,35 @@ defmodule FarmbotCeleryScript.Compiler do end end + # `Assert` is a internal node useful for self testing. + compile :assertion, %{lua: expression, op: op, _then: then_ast} do + quote location: :keep do + case FarmbotCeleryScript.SysCalls.eval_assertion(unquote(compile_ast(expression))) do + {:error, reason} -> + {:error, reason} + + true -> + :ok + + false when unquote(op) == "abort" -> + FarmbotCeleryScript.SysCalls.log("Assertion failed (aborting)") + {:error, "Assertion failed (aborting)"} + + false when unquote(op) == "recover" -> + FarmbotCeleryScript.SysCalls.log("Assertion failed (recovering)") + unquote(compile_block(then_ast)) + + false when unquote(op) == "abort_recover" -> + FarmbotCeleryScript.SysCalls.log("Assertion failed (recovering then aborting)") + unquote(compile_block([then_ast, %AST{kind: :abort, args: %{}}])) + end + end + end + # Compiles an if statement. compile :_if, %{_then: then_ast, _else: else_ast, lhs: lhs, op: op, rhs: rhs} do + rhs = compile_ast(rhs) + # Turns the left hand side arg into # a number. x, y, z, and pin{number} are special that need to be # evaluated before evaluating the if statement. @@ -188,6 +215,10 @@ defmodule FarmbotCeleryScript.Compiler do quote [location: :keep], do: FarmbotCeleryScript.SysCalls.read_pin(unquote(String.to_integer(pin)), nil) + "expression" -> + quote [location: :keep], + do: FarmbotCeleryScript.SysCalls.eval_assertion(rhs) + # Named pin has two intents here # in this case we want to read the named pin. %AST{kind: :named_pin} = ast -> @@ -198,8 +229,6 @@ defmodule FarmbotCeleryScript.Compiler do compile_ast(ast) end - rhs = compile_ast(rhs) - # Turn the `op` arg into Elixir code if_eval = case op do @@ -238,6 +267,11 @@ defmodule FarmbotCeleryScript.Compiler do quote location: :keep do unquote(lhs) > unquote(rhs) end + + _ -> + quote location: :keep do + unquote(lhs) + end end # Finally, compile the entire if statement. @@ -336,6 +370,12 @@ defmodule FarmbotCeleryScript.Compiler do end end + compile :abort do + quote location: :keep do + Macro.escape({:error, "aborted"}) + end + end + # Compiles move_absolute compile :move_absolute, %{location: location, offset: offset, speed: speed} do quote location: :keep do diff --git a/farmbot_celery_script/lib/farmbot_celery_script/scheduler.ex b/farmbot_celery_script/lib/farmbot_celery_script/scheduler.ex index aa7605f4..31823db2 100644 --- a/farmbot_celery_script/lib/farmbot_celery_script/scheduler.ex +++ b/farmbot_celery_script/lib/farmbot_celery_script/scheduler.ex @@ -136,7 +136,7 @@ defmodule FarmbotCeleryScript.Scheduler do end def handle_info(:checkup, %{next: nil} = state) do - Logger.debug("Scheduling next checkup with no next") + # Logger.debug("Scheduling next checkup with no next") state |> schedule_next_checkup() diff --git a/farmbot_celery_script/lib/farmbot_celery_script/sys_calls.ex b/farmbot_celery_script/lib/farmbot_celery_script/sys_calls.ex index 7a9b088c..0195aa01 100644 --- a/farmbot_celery_script/lib/farmbot_celery_script/sys_calls.ex +++ b/farmbot_celery_script/lib/farmbot_celery_script/sys_calls.ex @@ -67,6 +67,20 @@ defmodule FarmbotCeleryScript.SysCalls do @callback log(message :: String.t()) :: any() @callback sequence_init_log(message :: String.t()) :: any() @callback sequence_complete_log(message :: String.t()) :: any() + @callback eval_assertion(expression :: String.t()) :: true | false | error() + + def eval_assertion(sys_calls \\ @sys_calls, expression) when is_binary(expression) do + case sys_calls.eval_assertion(expression) do + true -> + true + + false -> + false + + {:error, reason} when is_binary(reason) -> + or_error(sys_calls, :eval_assertion, [expression], reason) + end + end def log(sys_calls \\ @sys_calls, message) when is_binary(message) do apply(sys_calls, :log, [message]) diff --git a/farmbot_celery_script/lib/farmbot_celery_script/sys_calls/stubs.ex b/farmbot_celery_script/lib/farmbot_celery_script/sys_calls/stubs.ex index 704fc8a3..4af7d348 100644 --- a/farmbot_celery_script/lib/farmbot_celery_script/sys_calls/stubs.ex +++ b/farmbot_celery_script/lib/farmbot_celery_script/sys_calls/stubs.ex @@ -129,6 +129,9 @@ defmodule FarmbotCeleryScript.SysCalls.Stubs do @impl true def zero(axis), do: error(:zero, [axis]) + @impl true + def eval_assertion(expression), do: error(:eval_assertion, [expression]) + defp error(fun, _args) do msg = """ CeleryScript syscall stubbed: #{fun} diff --git a/farmbot_os/lib/farmbot_os/lua.ex b/farmbot_os/lib/farmbot_os/lua.ex new file mode 100644 index 00000000..2afbde78 --- /dev/null +++ b/farmbot_os/lib/farmbot_os/lua.ex @@ -0,0 +1,56 @@ +defmodule FarmbotOS.Lua do + @type t() :: tuple() + @type table() :: [{any, any}] + alias FarmbotOS.Lua.CeleryScript + + @doc """ + Evaluates some Lua code. The code should + return a boolean value. + """ + def eval_assertion(str) when is_binary(str) do + init() + |> set_table([:get_position], &CeleryScript.get_position/2) + |> set_table([:get_pins], &CeleryScript.get_pins/2) + |> set_table([:send_message], &CeleryScript.send_message/2) + |> set_table([:help], &CeleryScript.help/2) + |> set_table([:version], &CeleryScript.version/2) + |> eval(str) + |> case do + {:ok, [true | _]} -> + true + + {:ok, [false | _]} -> + false + + {:ok, [_, reason]} when is_binary(reason) -> + {:error, reason} + + {:ok, _data} -> + {:error, "bad return value from expression evaluation"} + + {:error, {:lua_error, _error, _lua}} -> + {:error, "lua runtime error evaluating expression"} + + {:error, {:badmatch, {:error, [{line, :luerl_parse, parse_error}], _}}} -> + {:error, "failed to parse expression (line:#{line}): #{IO.iodata_to_binary(parse_error)}"} + + error -> + error + end + end + + @spec init() :: t() + def init do + :luerl.init() + end + + @spec set_table(t(), Path.t(), any()) :: t() + def set_table(lua, path, value) do + :luerl.set_table(path, value, lua) + end + + @spec eval(t(), String.t()) :: {:ok, any()} | {:error, any()} + def eval(lua, hook) when is_binary(hook) do + :luerl.eval(hook, lua) + end +end diff --git a/farmbot_os/lib/farmbot_os/lua/celery_script.ex b/farmbot_os/lib/farmbot_os/lua/celery_script.ex new file mode 100644 index 00000000..e34c63fe --- /dev/null +++ b/farmbot_os/lib/farmbot_os/lua/celery_script.ex @@ -0,0 +1,147 @@ +defmodule FarmbotOS.Lua.CeleryScript do + alias FarmbotCeleryScript.SysCalls + + @doc """ + Returns a table containing position data + + ## Example + + print("x", farmbot.get_position().x); + print("y", farmbot.get_position()["y"]); + position = farmbot.get_position(); + print("z", position.z); + """ + def get_position(["x"], lua) do + case SysCalls.get_current_x() do + x when is_number(x) -> + {[x, nil], lua} + + {:error, reason} -> + {[nil, reason], lua} + end + end + + def get_position(["y"], lua) do + case SysCalls.get_current_y() do + y when is_number(y) -> + {[y, nil], lua} + + {:error, reason} -> + {[nil, reason], lua} + end + end + + def get_position(["z"], lua) do + case SysCalls.get_current_z() do + z when is_number(z) -> + {[z, nil], lua} + + {:error, reason} -> + {[nil, reason], lua} + end + end + + def get_position(_args, lua) do + with x when is_number(x) <- SysCalls.get_current_x(), + y when is_number(y) <- SysCalls.get_current_y(), + z when is_number(z) <- SysCalls.get_current_z() do + {[[{"x", x}, {"y", y}, {"z", z}], nil], lua} + else + {:error, reason} -> + {[nil, reason], lua} + end + end + + @doc """ + Returns a table with pins data + + ## Example + + print("pin9", farmbot.get_pin()["9"]); + """ + def get_pins(_args, lua) do + case do_get_pins(Enum.to_list(0..69)) do + {:ok, contents} -> + {[contents, nil], lua} + + {:error, reason} -> + {[nil, reason], lua} + end + end + + @doc """ + # Example Usage + + ## With channels + + farmbot.send_message("info", "hello, world", ["email", "toast"]) + + ## No channels + + farmbot.send_message("info", "hello, world") + + """ + def send_message([kind, message], lua) do + do_send_message(kind, message, [], lua) + end + + def send_message([kind, message | channels], lua) do + channels = Enum.map(channels, &String.to_atom/1) + do_send_message(kind, message, channels, lua) + end + + @doc """ + Returns help docs about a function. + """ + def help([function_name], lua) do + function_name = String.to_atom(function_name) + + case Code.fetch_docs(__MODULE__) do + {:docs_v1, _, _, _, _, _, docs} -> + docs = + Enum.find_value(docs, fn + {{:function, ^function_name, _arity}, _, _, %{"en" => docs}, _} -> + IO.iodata_to_binary(docs) + + _other -> + false + end) + + if docs, + do: {[docs, nil], lua}, + else: {[nil, "docs not found"], lua} + + {:error, reason} -> + {[nil, reason], lua} + end + end + + @doc "Returns the current version of farmbot." + def version(_args, lua) do + {[FarmbotCore.Project.version(), nil], lua} + end + + defp do_send_message(kind, message, channels, lua) do + case SysCalls.send_message(kind, message, channels) do + :ok -> + {[true, nil], lua} + + {:error, reason} -> + {[nil, reason], lua} + end + end + + defp do_get_pins(nums, acc \\ []) + + defp do_get_pins([p | rest], acc) do + case FarmbotFirmware.request({:pin_read, [p: p]}) do + {:ok, {_, {:report_pin_value, [p: ^p, v: v]}}} -> + do_get_pins(rest, [{to_string(p), v} | acc]) + + er -> + er + end + end + + defp do_get_pins([], acc), do: {:ok, Enum.reverse(acc)} +end diff --git a/farmbot_os/lib/farmbot_os/sys_calls.ex b/farmbot_os/lib/farmbot_os/sys_calls.ex index a10bab18..a866f769 100644 --- a/farmbot_os/lib/farmbot_os/sys_calls.ex +++ b/farmbot_os/lib/farmbot_os/sys_calls.ex @@ -23,6 +23,8 @@ defmodule FarmbotOS.SysCalls do SetPinIOMode } + alias FarmbotOS.Lua + alias FarmbotCore.{Asset, Asset.Repo, Asset.Private, Asset.Sync, BotState, Leds} alias FarmbotExt.{API, API.Reconciler, API.SyncGroup} @@ -58,6 +60,9 @@ defmodule FarmbotOS.SysCalls do @impl true defdelegate set_pin_io_mode(pin, mode), to: SetPinIOMode + @impl true + defdelegate eval_assertion(expression), to: Lua + @impl true def log(message) do if FarmbotCore.Asset.fbos_config(:sequence_body_log) do diff --git a/farmbot_os/mix.exs b/farmbot_os/mix.exs index 1f229528..e2642699 100644 --- a/farmbot_os/mix.exs +++ b/farmbot_os/mix.exs @@ -82,6 +82,7 @@ defmodule FarmbotOS.MixProject do {:nerves_hub_cli, "~> 0.7", runtime: false}, {:shoehorn, "~> 0.6"}, {:ring_logger, "~> 0.8"}, + {:luerl, github: "rvirding/luerl"}, # Host/test only dependencies. {:excoveralls, "~> 0.10", only: [:test], targets: [:host]}, diff --git a/farmbot_os/mix.lock b/farmbot_os/mix.lock index eaefcac6..09b78c27 100644 --- a/farmbot_os/mix.lock +++ b/farmbot_os/mix.lock @@ -33,6 +33,8 @@ "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, "jsx": {:hex, :jsx, "2.9.0", "d2f6e5f069c00266cad52fb15d87c428579ea4d7d73a33669e12679e203329dd", [:mix, :rebar3], [], "hexpm"}, "lager": {:hex, :lager, "3.6.5", "831910109f3fcb503debf658ca0538836b348c58bfbf349a6d48228096ce9040", [:rebar3], [{:goldrush, "0.1.9", [hex: :goldrush, repo: "hexpm", optional: false]}], "hexpm"}, + "luer": {:git, "https://github.com/rvirding/luerl.git", "ce4e1b5a66a2a37efe2f8cd16e365ad9845b5015", []}, + "luerl": {:git, "https://github.com/rvirding/luerl.git", "ce4e1b5a66a2a37efe2f8cd16e365ad9845b5015", []}, "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, "mdns_lite": {:hex, :mdns_lite, "0.1.0", "efad834847576ab7641d1016754ec6f512d765788ae3718e1ac6a648c85eab12", [:mix], [{:dns, "~> 2.1", [hex: :dns, repo: "hexpm", optional: false]}], "hexpm"}, diff --git a/test/support/celery_script/test_sys_calls.ex b/test/support/celery_script/test_sys_calls.ex index c98f0687..c01497f1 100644 --- a/test/support/celery_script/test_sys_calls.ex +++ b/test/support/celery_script/test_sys_calls.ex @@ -241,6 +241,11 @@ defmodule Farmbot.TestSupport.CeleryScript.TestSysCalls do call({:zero, [axis]}) end + @impl true + def eval_assertion(expression) do + call({:eval_assertion, [expression]}) + end + defp call(data) do {handler, kind, args} = GenServer.call(__MODULE__, data, :infinity) handler.(kind, args)