defmodule SvPortSim do
@moduledoc """
A `SvPortSim` process owns exactly one simulator transport. The process
implementation lives in `SvPortSim.Server`; this module is the public facade
that keeps the stable user-facing API, validates command options, normalizes
command bodies, and delegates normalized runtime operations to the server.
In production the default transport opens one external wrapper process with
the port framing documented by `SvPortSim.Protocol`; tests and alternative
runtimes may provide their own `SvPortSim.Transport` implementation.
## Public contract
The initial stable public API surface is returned by `public_functions/0`:
* `start_link/1` and `start/1`
* `child_spec/1`
* `reset/2`
* `tick/2`
* `poke/4`
* `peek/3`
* `stop/2`
Default-argument arities such as `reset/1`, `tick/1`, `poke/3`, `peek/2`,
and `stop/1` are exported for convenience.
Runtime commands return `{:ok, body}` for a successful wrapper response or
`{:error, error_body}` for wrapper-side and Elixir-side failures. Error
bodies follow the canonical shape documented by `SvPortSim.Protocol` and
include string keys such as `"code"`, `"message"`, `"details"`, and
`"fatal"`. `stop/2` is terminal and returns `:ok` after a successful
shutdown.
## Options
`start_link/1` and `start/1` accept these simulation options:
* `:executable` - path to the generated simulator wrapper executable.
This is required when using the default `SvPortSim.Transport.Port`
transport.
* `:args` - command-line arguments passed to the wrapper executable.
Defaults to `[]`.
* `:transport` - module implementing `SvPortSim.Transport`. Defaults to
`SvPortSim.Transport.Port`.
* `:transport_opts` - extra keyword options passed to the transport.
Defaults to `[]`.
* `:default_timeout` - runtime command timeout in milliseconds or
`:infinity`. Defaults to `5_000`.
Standard `GenServer` start options `:name`, `:debug`, `:spawn_opt`,
`:hibernate_after`, and `:timeout` are also accepted.
Runtime calls accept `:timeout` to override the instance default. `reset/2`
accepts `:cycles` and `:reset`; `tick/2` accepts `:cycles` and `:clock`.
`poke/4` accepts encoded bit-vector values in the shape `%{bits: bits,
width: width}` or `%{"bits" => bits, "width" => width}`. `bits` must be a
string of `0`, `1`, `x`, or `z` characters whose length equals `width`.
## Examples
iex> defmodule SvPortSim.DocTransport do
...> @behaviour Elixir.SvPortSim.Transport
...>
...> def open(_opts), do: {:ok, %{signals: %{}}}
...>
...> def request(%{"op" => "reset"} = request, state, _timeout) do
...> body = %{"cycles" => request["body"]["cycles"]}
...> {:ok, response(request, body), %{state | signals: %{}}}
...> end
...>
...> def request(%{"op" => "tick"} = request, state, _timeout) do
...> body = %{"cycles" => request["body"]["cycles"]}
...> {:ok, response(request, body), state}
...> end
...>
...> def request(%{"op" => "poke"} = request, state, _timeout) do
...> signal = request["body"]["signal"]
...> value = request["body"]["value"]
...> state = %{state | signals: Map.put(state.signals, signal, value)}
...> {:ok, response(request, %{"signal" => signal}), state}
...> end
...>
...> def request(%{"op" => "peek"} = request, state, _timeout) do
...> signal = request["body"]["signal"]
...> value = Map.get(state.signals, signal, %{"bits" => "0", "width" => 1})
...> {:ok, response(request, %{"value" => value}), state}
...> end
...>
...> def request(%{"op" => "shutdown"} = request, state, _timeout) do
...> {:ok, response(request, %{}), state}
...> end
...>
...> def close(_state), do: :ok
...>
...> defp response(request, body) do
...> %{
...> "v" => 1,
...> "id" => request["id"],
...> "kind" => "response",
...> "op" => request["op"],
...> "body" => body
...> }
...> end
...> end
iex> {:ok, sim} = Elixir.SvPortSim.start_link(transport: SvPortSim.DocTransport)
iex> Elixir.SvPortSim.reset(sim, cycles: 2)
{:ok, %{"cycles" => 2}}
iex> Elixir.SvPortSim.tick(sim)
{:ok, %{"cycles" => 1}}
iex> Elixir.SvPortSim.poke(sim, "enable", %{bits: "1", width: 1})
{:ok, %{"signal" => "enable"}}
iex> Elixir.SvPortSim.peek(sim, "enable")
{:ok, %{"value" => %{"bits" => "1", "width" => 1}}}
iex> Elixir.SvPortSim.stop(sim)
:ok
iex> Elixir.SvPortSim.start_link([])
{:error, {:missing_required_option, :executable}}
iex> {:reset, 2} in Elixir.SvPortSim.public_functions()
true
"""
alias SvPortSim.Protocol
alias SvPortSim.Server
@public_functions [
{:child_spec, 1},
{:start_link, 1},
{:start, 1},
{:reset, 1},
{:reset, 2},
{:tick, 1},
{:tick, 2},
{:poke, 3},
{:poke, 4},
{:peek, 2},
{:peek, 3},
{:stop, 1},
{:stop, 2},
{:public_functions, 0}
]
@typedoc "A running simulation instance pid, registered name, or via tuple."
@type instance :: GenServer.server()
@typedoc "A top-level SystemVerilog signal name exposed by the wrapper."
@type signal :: atom() | String.t()
@typedoc "Encoded runtime bit-vector value accepted by `poke/4`."
@type encoded_value :: %{
optional(:bits) => String.t(),
optional(:width) => pos_integer(),
optional(String.t()) => String.t() | pos_integer()
}
@typedoc "Command response body returned by the wrapper."
@type response_body :: %{required(String.t()) => term()}
@typedoc "Canonical runtime error body documented by `SvPortSim.Protocol`."
@type error_body :: %{required(String.t()) => term()}
@typedoc "Public API timeout in milliseconds or `:infinity`."
@type timeout_ms :: pos_integer() | :infinity
@typedoc "Public API result returned by runtime commands except `stop/2`."
@type api_result :: {:ok, response_body()} | {:error, error_body()}
@typedoc "Options accepted by `start_link/1`, `start/1`, and `child_spec/1`."
@type start_option ::
{:executable, Path.t()}
| {:args, [String.t()]}
| {:transport, module()}
| {:transport_opts, keyword()}
| {:default_timeout, timeout()}
| {:name, GenServer.name()}
| {:debug, term()}
| {:spawn_opt, [term()]}
| {:hibernate_after, non_neg_integer()}
| {:timeout, timeout_ms()}
| {:id, term()}
@typedoc "Per-command options accepted by `poke/4`, `peek/3`, and `stop/2`."
@type command_option :: {:timeout, timeout_ms()}
@typedoc "Options accepted by `reset/2`."
@type reset_option ::
command_option()
| {:cycles, pos_integer()}
| {:reset, signal()}
| {:clock, signal()}
@typedoc "Options accepted by `tick/2`."
@type tick_option :: command_option() | {:cycles, pos_integer()} | {:clock, signal()}
@doc """
Returns the stable public API surface for one simulation instance.
Internal server functions are intentionally not included in this list.
## Examples
iex> SvPortSim.public_functions() |> Enum.member?({:start_link, 1})
true
iex> SvPortSim.public_functions() |> Enum.member?({:poke, 4})
true
"""
@spec public_functions() :: [{atom(), non_neg_integer()}]
def public_functions(), do: @public_functions
@doc """
Starts one simulation instance and links it to the caller.
"""
@spec start_link([start_option()]) :: GenServer.on_start()
def start_link(opts) when is_list(opts), do: Server.start_link(opts)
def start_link(opts), do: {:error, {:invalid_options, opts}}
@doc """
Starts one simulation instance without linking it to the caller.
"""
@spec start([start_option()]) :: GenServer.on_start()
def start(opts) when is_list(opts), do: Server.start(opts)
def start(opts), do: {:error, {:invalid_options, opts}}
@doc """
Returns a child spec for supervising one simulation instance.
Pass `:id` to override the child id. Other options are passed to
`start_link/1`.
"""
@spec child_spec([start_option()]) :: Supervisor.child_spec()
def child_spec(opts) when is_list(opts) do
if Keyword.keyword?(opts) do
id = Keyword.get(opts, :id, __MODULE__)
start_opts = Keyword.delete(opts, :id)
%{
id: id,
start: {__MODULE__, :start_link, [start_opts]},
type: :worker,
restart: :permanent,
shutdown: 5_000
}
else
raise ArgumentError, "child_spec/1 expects a keyword list"
end
end
@doc """
Resets the simulator.
Options:
* `:cycles` - number of reset cycles. Defaults to `1`.
* `:reset` - reset signal name. Defaults to wrapper policy.
* `:clock` - clock signal name. Defaults to wrapper policy.
* `:timeout` - per-request timeout.
"""
@spec reset(instance(), [reset_option()]) :: api_result()
def reset(instance, opts \\ []) do
with {:ok, body} <- build_reset_body(opts) do
request(instance, "reset", body, opts)
end
end
@doc """
Advances the simulator by one or more clock cycles.
Options:
* `:cycles` - number of cycles. Defaults to `1`.
* `:clock` - clock signal name. Defaults to wrapper policy.
* `:timeout` - per-request timeout.
"""
@spec tick(instance(), [tick_option()]) :: api_result()
def tick(instance, opts \\ []) do
with {:ok, body} <- build_tick_body(opts) do
request(instance, "tick", body, opts)
end
end
@doc """
Writes an encoded value to a signal.
`encoded_value` must be `%{bits: bits, width: width}` or
`%{"bits" => bits, "width" => width}`. The value is normalized to
string-keyed JSON-compatible data before it is sent to the transport.
"""
@spec poke(instance(), signal(), encoded_value(), [command_option()]) :: api_result()
def poke(instance, signal, encoded_value, opts \\ []) do
with {:ok, _opts} <- validate_keyword_options(opts),
{:ok, _opts} <- validate_allowed_options(opts, [:timeout]),
{:ok, signal} <- normalize_signal(signal),
{:ok, value} <- normalize_encoded_value(encoded_value) do
request(instance, "poke", %{"signal" => signal, "value" => value}, opts)
end
end
@doc """
Reads a signal from the simulator.
"""
@spec peek(instance(), signal(), [command_option()]) :: api_result()
def peek(instance, signal, opts \\ []) do
with {:ok, _opts} <- validate_keyword_options(opts),
{:ok, _opts} <- validate_allowed_options(opts, [:timeout]),
{:ok, signal} <- normalize_signal(signal) do
request(instance, "peek", %{"signal" => signal}, opts)
end
end
@doc """
Sends the terminal shutdown command and stops the simulation instance.
Returns `:ok` after the wrapper acknowledges shutdown and the transport is
closed. Returns `{:error, error_body}` if the shutdown request fails.
"""
@spec stop(instance(), [command_option()]) :: :ok | {:error, error_body()}
def stop(instance, opts \\ []) do
with {:ok, _opts} <- validate_keyword_options(opts),
{:ok, _opts} <- validate_allowed_options(opts, [:timeout]),
{:ok, timeout_override} <- command_timeout_override(opts) do
Server.stop(instance, timeout_override)
end
end
defp request(instance, op, body, opts) do
with {:ok, timeout_override} <- command_timeout_override(opts) do
Server.request(instance, op, body, timeout_override)
end
end
defp build_reset_body(opts) do
with {:ok, _opts} <- validate_keyword_options(opts),
{:ok, _opts} <- validate_allowed_options(opts, [:cycles, :reset, :clock, :timeout]),
{:ok, cycles} <- positive_integer_option(opts, :cycles, 1),
{:ok, body} <- optional_signal(%{"cycles" => cycles}, opts, :reset) do
optional_signal(body, opts, :clock)
end
end
defp build_tick_body(opts) do
with {:ok, _opts} <- validate_keyword_options(opts),
{:ok, _opts} <- validate_allowed_options(opts, [:cycles, :clock, :timeout]),
{:ok, cycles} <- positive_integer_option(opts, :cycles, 1) do
optional_signal(%{"cycles" => cycles}, opts, :clock)
end
end
defp optional_signal(body, opts, key) do
case Keyword.fetch(opts, key) do
:error ->
{:ok, body}
{:ok, signal} ->
with {:ok, signal} <- normalize_signal(signal) do
{:ok, Map.put(body, Atom.to_string(key), signal)}
end
end
end
defp validate_keyword_options(opts) when is_list(opts) do
if Keyword.keyword?(opts) do
{:ok, opts}
else
validation_error("options must be a keyword list", %{"options" => inspect(opts)})
end
end
defp validate_keyword_options(opts) do
validation_error("options must be a keyword list", %{"options" => inspect(opts)})
end
defp validate_allowed_options(opts, allowed) do
unknown = Keyword.keys(opts) -- allowed
if unknown == [] do
{:ok, opts}
else
validation_error("unknown option", %{
"allowed" => Enum.map(allowed, &Atom.to_string/1),
"unknown" => Enum.map(unknown, &Atom.to_string/1)
})
end
end
defp positive_integer_option(opts, key, default) do
value = Keyword.get(opts, key, default)
if is_integer(value) and value > 0 do
{:ok, value}
else
validation_error("#{key} must be a positive integer", %{Atom.to_string(key) => value})
end
end
defp command_timeout_override(opts) do
command_timeout_override_case(validate_keyword_options(opts))
end
defp command_timeout_override_case({:ok, opts}) do
case Keyword.fetch(opts, :timeout) do
:error ->
{:ok, :default}
{:ok, timeout} ->
case Protocol.normalize_timeout(timeout) do
{:ok, normalized} ->
{:ok, normalized}
{:error, reason} ->
validation_error("timeout must be a positive integer or :infinity", %{
"reason" => inspect(reason),
"timeout" => inspect(timeout)
})
end
end
end
defp command_timeout_override_case({:error, _error_body} = error), do: error
defp normalize_signal(signal) when is_atom(signal) and signal not in [nil, true, false] do
signal
|> Atom.to_string()
|> normalize_signal()
end
defp normalize_signal(signal) when is_binary(signal) and byte_size(signal) > 0 do
{:ok, signal}
end
defp normalize_signal(signal) do
validation_error("signal must be a non-empty string or atom", %{"signal" => inspect(signal)})
end
defp normalize_encoded_value(%{} = encoded_value) do
bits = Map.get(encoded_value, :bits, Map.get(encoded_value, "bits"))
width = Map.get(encoded_value, :width, Map.get(encoded_value, "width"))
cond do
not is_binary(bits) ->
validation_error("encoded value bits must be a string", %{"bits" => inspect(bits)})
not (is_integer(width) and width > 0) ->
validation_error("encoded value width must be a positive integer", %{
"width" => inspect(width)
})
String.length(bits) != width ->
validation_error("encoded value width must match bit-string length", %{
"bits" => bits,
"width" => width
})
not valid_bit_string?(bits) ->
validation_error("encoded value bits may only contain 0, 1, x, or z", %{"bits" => bits})
true ->
{:ok, %{"bits" => bits, "width" => width}}
end
end
defp normalize_encoded_value(value) do
validation_error("encoded value must be a map", %{"value" => inspect(value)})
end
defp valid_bit_string?(bits) do
bits
|> String.to_charlist()
|> Enum.all?(&(&1 in ~c"01xz"))
end
defp validation_error(message, details) do
case Protocol.error_body("invalid_request", message, details) do
{:ok, body} ->
{:error, body}
{:error, reason} ->
{:error,
%{
"code" => "invalid_request",
"message" => inspect(reason),
"details" => details,
"fatal" => false
}}
end
end
end