# SPDX-License-Identifier: MIT # Copyright (c) 2025 Scott Thompson defmodule ArduinoRouter.Bridge do @moduledoc """ Elixir interface to the Arduino Router Bridge. This module is modeled after the Sketch and Python Bridge modules from the Arduino Uno Q documentation. It provides a GenServer-based interface for communicating with other programs running on the device's processors through RPC calls. In common usage, the module takes advantage of the Unix socket interface provided by the arduino-router to exchange RPC messages. This makes it possible to send, and receive, RPC calls from the router. It is primarily intended for interaction between Elixir and an Arduino Sketch running on the MCU, but it can also be used as an interprocess communication mechanism on the MPU. The actual transport mechanism is abstracted by the `ArduinoRouter.Transport` behaviour (primarily for unit testing). ## Usage Start the bridge and register methods to handle incoming calls: {:ok, bridge} = ArduinoRouter.Bridge.start_link() ArduinoRouter.Bridge.provide("led_on", MyModule, :turn_on_led) Make calls through the bridge: {:ok, result} = ArduinoRouter.Bridge.call("get_sensor_reading", []) Send notifications (fire-and-forget): ArduinoRouter.Bridge.notify("log_message", ["Hello from Elixir"]) """ require Logger require :telemetry use GenServer alias ArduinoRouter.Socket @type t :: %__MODULE__{ transport_module: module(), transport_state: ArduinoRouter.Transport.state(), registered_methods: %{MessagePackRPC.method_name() => {module(), atom()}}, calls_in_flight: %{MessagePackRPC.message_id() => pid()}, next_id: MessagePackRPC.message_id() } defstruct transport_module: nil, transport_state: nil, registered_methods: %{}, calls_in_flight: %{}, next_id: 1 @doc """ Register the given module and function to handle an incoming method call or notification. The function will be invoked MFA style with the params extracted from the RPC message. The function registered is expected to return {:ok, result} or {:error, reason}. `result` and `reason` must be MessagePack serializable. """ @spec provide(MessagePackRPC.method_name(), module(), atom()) :: {:ok, any()} | {:error, any()} def provide(method, module, function) when is_binary(method) and is_atom(module) and is_atom(function) do case call("$/register", [method]) do {:ok, _result} = result -> :telemetry.execute([:arduino_router, :bridge, :register_method], %{}, %{ method: method, module: module, function: function }) GenServer.call(__MODULE__, {:register, method, module, function}) result {:error, reason} -> {:error, reason} end end @doc """ Removes *all* provided methods from the arduino-router. """ @spec reset() :: {:ok, MessagePackRPC.result()} | {:error, MessagePackRPC.error()} def reset() do response = call("$/reset", []) :telemetry.execute([:arduino_router, :bridge, :reset_methods], %{}, %{}) response end @doc """ Asks the bridge for its version number """ @spec version() :: {:ok, String.t()} | {:error, MessagePackRPC.error()} def version() do case call("$/version", []) do {:ok, version} = result -> :telemetry.execute([:arduino_router, :bridge, :version_request], %{}, %{version: version}) result {:error, _reason} = error -> error end end @doc """ Make a synchronous RPC call to the arduino-router with the given method and parameters. The parameters list should be MessagePack serializable. Following the convention of other implementations of the bridge, this function will block until a response is received. """ @spec call(MessagePackRPC.method_name(), MessagePackRPC.params()) :: {:ok, MessagePackRPC.result()} | {:error, MessagePackRPC.error()} @spec call(MessagePackRPC.method_name(), MessagePackRPC.params(), timeout()) :: {:ok, MessagePackRPC.result()} | {:error, MessagePackRPC.error()} def call(method, params, timeout \\ 5000) when is_binary(method) and is_list(params) do start_time = System.monotonic_time() result = Task.async(fn -> GenServer.cast(__MODULE__, {:arduino_router_call, self(), method, params}) receive do {:arduino_router_response, result} -> result end end) |> Task.await(timeout) duration = System.monotonic_time() - start_time :telemetry.execute( [:arduino_router, :bridge, :call], %{duration: duration}, %{method: method, result: elem(result, 0)} ) result end @doc """ This mechanism is not really found in other implementations of the bridge, but makes sense in Elixir. Make an asynchronous RPC call to the arduino-router with the given method and parameters. `call_async` will return `:ok` immediately. When the arduino-router returns the RPC call's response, a message is sent to the process that invoked `call_async`, only, in the format `{:arduino_router_response, {:ok, result}}` or `{:arduino_router_response, {:error, reason}}`. Callers must explicitly `receive` the message to obtain the asynchronous result. """ @spec call_async(MessagePackRPC.method_name(), MessagePackRPC.params()) :: :ok def call_async(method, params) when is_binary(method) and is_list(params) do GenServer.cast(__MODULE__, {:arduino_router_call, self(), method, params}) :telemetry.execute( [:arduino_router, :bridge, :call_async], %{}, %{method: method} ) :ok end @doc """ Send an asynchronous RPC notification to the arduino-router with the given method and parameters. This call will return immediately since no response is expected. """ @spec notify(MessagePackRPC.method_name(), MessagePackRPC.params()) :: :ok def notify(method, params) when is_binary(method) and is_list(params) do GenServer.cast(__MODULE__, {:notify, method, params}) end @doc """ Returns a child specification for starting ArduinoRouter.Bridge under a supervisor. ## Options See the start_link/1 function for available options. ## Examples # Use default settings {ArduinoRouter.Bridge, []} """ @spec child_spec(keyword()) :: Supervisor.child_spec() def child_spec(opts) do %{ id: __MODULE__, start: {__MODULE__, :start_link, [opts]}, type: :worker, restart: :permanent, shutdown: 5000 } end @doc """ Start the bridge. It is registered under the module name. ## Options :transport - A tuple specifying the transport module and its options. The default value is `{ArduinoRouter.Socket, []}`. """ @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts \\ []) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @impl GenServer @spec init(keyword()) :: {:ok, t()} def init(opts) do {transport_module, transport_opts} = Keyword.get(opts, :transport, {Socket, []}) case transport_module.start(self(), transport_opts) do {:ok, transport_state} -> {:ok, %__MODULE__{transport_module: transport_module, transport_state: transport_state}} {:error, reason} -> {:stop, reason} end end @impl GenServer def handle_call({:register, method, module, function}, _sender, state) do {:reply, :ok, put_in( state, [Access.key(:registered_methods, %{}), Access.key(method)], {module, function} )} end @impl GenServer def handle_cast({:arduino_router_call, caller, method, params}, state) do # Generate a message_id {message_id, new_state} = next_message_id(state) # Register the caller under that message_id new_state = register_in_flight_call(new_state, message_id, caller) new_transport_state = state.transport_module.send_message( state.transport_state, MessagePackRPC.rpc_request(message_id, method, params) ) # Send a request message through the socket {:noreply, %{new_state | transport_state: new_transport_state}} end def handle_cast({:notify, method, params}, state) do new_transport_state = state.transport_module.send_message( state.transport_state, MessagePackRPC.rpc_notification(method, params) ) {:noreply, %__MODULE__{state | transport_state: new_transport_state}} end @impl GenServer def handle_info({:incoming_rpc, rpc_message}, state) do new_state = handle_incoming_rpc(rpc_message, state) {:noreply, new_state} end @impl GenServer def terminate(_reason, state) do state.transport_module.stop(state.transport_state) end # Handle the result of a successfully parsed incoming RPC message. @spec handle_incoming_rpc(MessagePackRPC.rpc_message(), t()) :: t() defp handle_incoming_rpc({:response, {:ok, message_id, result}}, state) do caller = registered_caller(state, message_id) if nil != caller do send(caller, {:arduino_router_response, {:ok, result}}) else Logger.error("No registered caller found for message ID #{message_id}") end complete_call(state, message_id) end defp handle_incoming_rpc({:response, {:error, message_id, error}}, state) do caller = registered_caller(state, message_id) if nil != caller do send(caller, {:arduino_router_response, {:error, error}}) else Logger.error("No registered caller found for message ID #{message_id}") end complete_call(state, message_id) end defp handle_incoming_rpc({:request, {message_id, method_name, params}}, state) do case registered_handler(state, method_name) do {module, function} -> call_registered_method(module, function, params) |> send_rpc_response(message_id, state) nil -> Logger.error("No handler registered for requested method #{method_name}") send_rpc_response({:error, "Method implementation not found"}, message_id, state) end end defp handle_incoming_rpc({:notify, {method_name, params}}, state) do case registered_handler(state, method_name) do {module, function} -> call_registered_method(module, function, params) # The function result is ignored as this is a notification. nil -> Logger.error("No handler registered for notify method #{method_name}") end state end @spec send_rpc_response( {:ok, MessagePackRPC.result()} | {:error, MessagePackRPC.error()}, MessagePackRPC.message_id(), t() ) :: t() defp send_rpc_response({:ok, result}, message_id, state) do message = MessagePackRPC.successful_response_message(message_id, result) state.transport_module.send_message(state.transport_state, message) state end defp send_rpc_response({:error, error}, message_id, state) do message = MessagePackRPC.error_response_message(message_id, error) state.transport_module.send_message(state.transport_state, message) state end # Just a short utility method to call the registered module/function # and translate exceptions into errors. @spec call_registered_method(module(), atom(), MessagePackRPC.params()) :: {:ok, MessagePackRPC.result()} | {:error, MessagePackRPC.error()} defp call_registered_method(module, function, args) do try do apply(module, function, args) rescue exception -> {:error, Exception.format(:error, exception, __STACKTRACE__)} end end # Returns the handler ({module, function}) registered for the given method name. # or nil if none is registered @spec registered_handler(t(), MessagePackRPC.method_name()) :: {module(), atom()} | nil defp registered_handler(state, method) do get_in(state, [Access.key(:registered_methods, %{}), Access.key(method)]) end # Registers an in-flight call so that when the response is returned by the # arduino-router we can send the result back to the caller. @spec register_in_flight_call(t(), MessagePackRPC.message_id(), pid()) :: t() defp register_in_flight_call(state, message_id, caller) do put_in(state, [Access.key(:calls_in_flight, %{}), Access.key(message_id)], caller) end # Retrieves the caller registered for the given message_id. @spec registered_caller(t(), MessagePackRPC.message_id()) :: pid() | nil defp registered_caller(state, message_id) do get_in(state, [Access.key(:calls_in_flight, %{}), Access.key(message_id)]) end # Completes the call by removing it from the in-flight calls. @spec complete_call(t(), MessagePackRPC.message_id()) :: t() defp complete_call(state, message_id) do %__MODULE__{state | calls_in_flight: Map.delete(state.calls_in_flight, message_id)} end # Generates the next message ID. Returns the new id and an updated state @spec next_message_id(t()) :: {MessagePackRPC.message_id(), t()} defp next_message_id(state) do {state.next_id, %__MODULE__{state | next_id: rem(state.next_id + 1, 0xFFFF)}} end end