Packages

SvPortSim: Elixir interface for driving Verilated SystemVerilog modules through Ports, with each simulation instance managed as a GenServer.

Current section

Files

Jump to
sv_port_sim lib sv_port_sim protocol command.ex
Raw

lib/sv_port_sim/protocol/command.ex

defmodule SvPortSim.Protocol.Command do
@moduledoc """
Defines the MVP command and response protocol for the C++ wrapper process.
`SvPortSim.Protocol` defines the length-prefixed JSON envelope. This module defines the
commands carried by that envelope.
The MVP command names are:
* `"metadata"` - discover the top module, signal metadata, protocol version, and current
simulation cycle
* `"reset"` - drive the wrapper-defined reset sequence
* `"poke"` - assign one writable Verilated top-level signal
* `"tick"` - advance the simulation by one or more clock cycles
* `"peek"` - read one Verilated top-level signal
* `"shutdown"` - flush the final response and terminate the wrapper process
## Runtime model and lifecycle
The MVP supports exactly one simulation instance per OS process. On startup, the C++ wrapper
constructs one `VerilatedContext` and one Verilated top-module instance, initializes its
internal cycle counter to `0`, then waits for framed JSON requests on standard input. There is
no `instance_id` field in the MVP.
The protocol is synchronous. The Elixir side sends one request, waits for the matching response,
and only then sends the next request. The wrapper must read requests in frame order and must
emit exactly one response or error envelope for each well-formed request envelope, using the
same `id` and `op`.
If the frame, JSON payload, or common envelope is not recoverable enough to identify an `id`,
the wrapper should treat that condition as a fatal protocol error and exit non-zero. If an
envelope is well formed but the command name, body, signal name, value, unsupported feature, or
current simulator state is invalid, the wrapper must return a `kind: "error"` envelope and keep
running unless the error body explicitly sets `"fatal" => true`.
`"shutdown"` is the terminal command. After writing and flushing the successful shutdown
response, the wrapper calls `final()` on the Verilated model and exits with status `0`. No
further request is accepted by that process.
## Common request envelope
Every command request uses the common `SvPortSim.Protocol` envelope:
{
"v": 1,
"id": 1,
"kind": "request",
"op": "peek",
"body": {"signal": "count"}
}
Successful responses use `kind: "response"` and the same `id` and `op`. Command failures use
`kind: "error"` and the canonical runtime error body from `SvPortSim.Protocol.error_body/4`:
{
"code": "invalid_signal",
"message": "signal is not readable",
"details": {"signal": "enable"},
"fatal": false
}
## Command schemas
All object keys are JSON strings. Unknown request fields are invalid.
### `metadata`
* Request body: `{}`
* Response body: `%{"top" => top_module, "signals" => signal_specs, "cycle" => cycle}`
* Wrapper operation: return static metadata for the single model instance.
* Idempotency: idempotent; it must not call `eval()` or advance time.
`signals` is a list of metadata objects. The command layer intentionally only constrains it to
a list; the exact signal metadata schema is owned by the signal-spec contract.
### `reset`
* Request body: `%{"cycles" => positive_integer}`. `"cycles"` is optional and defaults to `1`.
* Response body: `%{"cycle" => cycle, "reset" => %{"cycles" => cycles}}`
* Wrapper operation: drive reset-role signal or signals active, advance the requested number
of cycles using the wrapper's clocking policy, deassert reset, settle the model with
`eval()`, and return the resulting cycle.
* Idempotency: not idempotent because it advances the cycle counter.
### `poke`
* Request body: `%{"signal" => writable_signal_name, "value" => encoded_value}`
* Response body: `%{"signal" => signal_name, "value" => encoded_value, "cycle" => cycle}`
* Wrapper operation: assign the decoded value to the matching Verilated top-level field, call
`eval()` once to settle combinational outputs, and return the canonical value actually
stored by the wrapper.
* Idempotency: idempotent for the same signal and value at the same cycle. It must not advance
the cycle counter.
`encoded_value` is `%{"bits" => bit_string, "width" => positive_integer}`. Bit strings are
most-significant bit first and may contain `0`, `1`, `x`, and `z`.
### `tick`
* Request body: `%{"cycles" => positive_integer, "clock" => signal_name}`. Both fields are
optional. `"cycles"` defaults to `1`. `"clock"` may be omitted only when wrapper metadata
defines exactly one clock signal.
* Response body: `%{"cycle" => cycle, "clock" => clock_name, "cycles" => cycles}`
* Wrapper operation: for each requested cycle, perform one complete clock cycle according to
the wrapper's clocking policy and call `eval()` at each driven edge needed by the generated
model. Increment the cycle counter by one per completed cycle.
* Idempotency: not idempotent because it advances simulation state.
### `peek`
* Request body: `%{"signal" => readable_signal_name}`
* Response body: `%{"signal" => signal_name, "value" => encoded_value, "cycle" => cycle}`
* Wrapper operation: read the matching Verilated top-level field after all previous commands
have completed. The wrapper may call `eval()` first if needed to settle combinational
outputs, but it must not advance the cycle counter.
* Idempotency: idempotent when no intervening state-changing command occurs.
### `shutdown`
* Request body: `{}`
* Response body: `%{"status" => "closing"}`
* Wrapper operation: send the response, flush stdout, call `final()`, release resources, and
exit with status `0`.
* Idempotency: terminal.
## Example protocol exchange
The following exchange is a complete reset/poke/tick/peek sequence for a minimal Verilated
counter wrapper. The example top module has one clock, one active-low reset, one writable input,
and one readable output:
module Counter(
input logic clk,
input logic rst_n,
input logic enable,
output logic [3:0] count
);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
count <= 4'd0;
end else if (enable) begin
count <= count + 4'd1;
end
end
endmodule
The wrapper starts at simulation cycle `0`. Each JSON value below is the exact UTF-8 payload
inside one protocol frame. On the actual C++ side, every payload is sent as
`uint32_be(byte_size(payload)) <> payload`. When Elixir opens the port with `{:packet, 4}`, it
sends and receives only the payload bytes because the BEAM adds and removes the 4-byte length
prefix.
JSON member order is not significant, but this example uses a compact stable order so C++ and
Elixir implementers can compare logs byte-for-byte.
### 1. Reset
Elixir sends an 80-byte payload (`0x00000050` length prefix) requesting a two-cycle reset on
`rst_n`:
```json
{"v":1,"id":1,"kind":"request","op":"reset","body":{"cycles":2,"reset":"rst_n"}}
```
The C++ wrapper drives reset active for two clock cycles, deasserts it, settles the model, and
returns the resulting cycle in a 102-byte payload (`0x00000066` length prefix):
```json
{"v":1,"id":1,"kind":"response","op":"reset","body":{"cycle":2,"reset":{"cycles":2,"signal":"rst_n"}}}
```
### 2. Poke
Elixir writes `enable = 1` using the canonical bit-string value encoding. The request is a
101-byte payload (`0x00000065` length prefix):
```json
{"v":1,"id":2,"kind":"request","op":"poke","body":{"signal":"enable","value":{"bits":"1","width":1}}}
```
The wrapper assigns the top-level signal, calls `eval()` to settle combinational logic, does not
advance the cycle counter, and echoes the stored canonical value in a 112-byte payload
(`0x00000070` length prefix):
```json
{"v":1,"id":2,"kind":"response","op":"poke","body":{"signal":"enable","value":{"bits":"1","width":1},"cycle":2}}
```
### 3. Tick
Elixir advances one full cycle on `clk`. The request is a 77-byte payload (`0x0000004d` length
prefix):
```json
{"v":1,"id":3,"kind":"request","op":"tick","body":{"clock":"clk","cycles":1}}
```
The wrapper performs one complete clock cycle, increments its cycle counter to `3`, and returns
an 88-byte payload (`0x00000058` length prefix):
```json
{"v":1,"id":3,"kind":"response","op":"tick","body":{"clock":"clk","cycles":1,"cycle":3}}
```
### 4. Peek
Elixir reads `count` without advancing time. The request is a 69-byte payload (`0x00000045`
length prefix):
```json
{"v":1,"id":4,"kind":"request","op":"peek","body":{"signal":"count"}}
```
Because reset put `count` at zero and the enabled tick incremented it once, the wrapper returns
`0001` as a 4-bit most-significant-bit-first value. The response is a 114-byte payload
(`0x00000072` length prefix):
```json
{"v":1,"id":4,"kind":"response","op":"peek","body":{"signal":"count","value":{"bits":"0001","width":4},"cycle":3}}
```
### 5. Non-fatal error example
A well-formed command for an unknown or unreadable signal is a command-layer error, not a fatal
protocol failure. Elixir sends a 71-byte payload (`0x00000047` length prefix):
```json
{"v":1,"id":5,"kind":"request","op":"peek","body":{"signal":"missing"}}
```
The wrapper returns one `kind: "error"` envelope with the same `id` and `op`, keeps the simulator
process running, and accepts the next request. The response is a 146-byte payload
(`0x00000092` length prefix):
```json
{"v":1,"id":5,"kind":"error","op":"peek","body":{"code":"invalid_signal","message":"unknown signal","details":{"signal":"missing"},"fatal":false}}
```
## Examples
The MVP command set is fixed and ordered for documentation and tests.
iex> SvPortSim.Protocol.Command.command_names()
["metadata", "reset", "eval", "poke", "tick", "cycle", "peek", "finish?", "shutdown"]
Metadata discovery uses an empty request body and reports the model metadata.
iex> {:ok, request} = SvPortSim.Protocol.Command.request("metadata", 1)
iex> {request["kind"], request["op"], request["body"]}
{"request", "metadata", %{}}
iex> {:ok, response} = SvPortSim.Protocol.Command.ok_response(request, %{"top" => "Counter", "signals" => [], "cycle" => 0})
iex> {response["kind"], response["body"]["top"], response["body"]["cycle"]}
{"response", "Counter", 0}
Reset advances the simulator through the wrapper-defined reset sequence.
iex> {:ok, request} = SvPortSim.Protocol.Command.request("reset", 2, %{"cycles" => 2})
iex> SvPortSim.Protocol.Command.validate_request(request)
:ok
iex> {:ok, response} = SvPortSim.Protocol.Command.ok_response(request, %{"cycle" => 2, "reset" => %{"cycles" => 2}})
iex> response["body"]["reset"]["cycles"]
2
Poke assigns one writable signal using the runtime bit-string encoding.
iex> value = %{"bits" => "1", "width" => 1}
iex> {:ok, request} = SvPortSim.Protocol.Command.request("poke", 3, %{"signal" => "enable", "value" => value})
iex> request["body"]["signal"]
"enable"
iex> {:ok, response} = SvPortSim.Protocol.Command.ok_response(request, %{"signal" => "enable", "value" => value, "cycle" => 2})
iex> response["body"]["value"]
%{"bits" => "1", "width" => 1}
Tick advances one or more complete clock cycles.
iex> {:ok, request} = SvPortSim.Protocol.Command.request("tick", 4, %{"clock" => "clk", "cycles" => 1})
iex> {:ok, response} = SvPortSim.Protocol.Command.ok_response(request, %{"clock" => "clk", "cycles" => 1, "cycle" => 3})
iex> {request["op"], response["body"]["cycle"]}
{"tick", 3}
Peek reads one signal without advancing the cycle counter.
iex> {:ok, request} = SvPortSim.Protocol.Command.request("peek", 5, %{"signal" => "count"})
iex> {:ok, response} = SvPortSim.Protocol.Command.ok_response(request, %{"signal" => "count", "value" => %{"bits" => "0001", "width" => 4}, "cycle" => 3})
iex> response["body"]["value"]["bits"]
"0001"
Shutdown is the final successful command in a wrapper process.
iex> {:ok, request} = SvPortSim.Protocol.Command.request("shutdown", 6)
iex> {:ok, response} = SvPortSim.Protocol.Command.ok_response(request, %{"status" => "closing"})
iex> response["body"]["status"]
"closing"
Unsupported commands and invalid command bodies are explicit error paths.
iex> SvPortSim.Protocol.Command.request("step", 7)
{:error, {:unsupported_command, "step"}}
iex> {:ok, request} = SvPortSim.Protocol.Command.request("peek", 8, %{"signal" => "count"})
iex> {:ok, error} = SvPortSim.Protocol.Command.error_response(request, "invalid_signal", "signal is not readable", %{"signal" => "count"})
iex> {error["kind"], error["body"]["code"], error["body"]["fatal"]}
{"error", "invalid_signal", false}
Fatal error responses can be built explicitly when the wrapper cannot safely continue.
iex> {:ok, error} = SvPortSim.Protocol.Command.error_response(9, "tick", "wrapper_fault", "segmentation fault", %{}, fatal: true)
iex> {error["kind"], error["body"]["code"], error["body"]["fatal"]}
{"error", "wrapper_fault", true}
"""
alias SvPortSim.Protocol
@command_names ~w(metadata reset eval poke tick cycle peek finish? shutdown)
@error_codes ~w(
invalid_command
unsupported_command
invalid_request
invalid_signal
invalid_value
invalid_state
unsupported_feature
protocol_error
timeout
malformed_output
simulator_exit
simulator_failure
port_closed
wrapper_fault
)
@command_specs [
%{
name: "metadata",
request: %{required: %{}, optional: %{}},
response: %{
required: %{"top" => "string", "signals" => "list", "cycle" => "non_neg_integer"},
optional: %{"protocol" => "object"}
},
wrapper_operation: "Return static metadata for the single Verilated model instance.",
idempotency: :idempotent,
errors:
~w(unsupported_command invalid_request invalid_state unsupported_feature wrapper_fault)
},
%{
name: "reset",
request: %{
required: %{},
optional: %{"cycles" => "positive_integer", "reset" => "string"},
defaults: %{"cycles" => 1}
},
response: %{
required: %{
"cycle" => "non_neg_integer",
"reset" => "%{\"cycles\" => positive_integer}"
},
optional: %{}
},
wrapper_operation:
"Drive reset active, tick the requested cycles, deassert reset, and settle with eval().",
idempotency: :state_changing,
errors:
~w(unsupported_command invalid_request invalid_signal invalid_value invalid_state unsupported_feature wrapper_fault)
},
%{
name: "eval",
request: %{required: %{}, optional: %{}},
response: %{
required: %{
"time" => "non_neg_integer",
"cycle" => "non_neg_integer"
},
optional: %{}
},
wrapper_operation:
"Call eval() once to settle the current model state without advancing simulation time or cycle.",
idempotency: :idempotent,
errors:
~w(unsupported_command invalid_request invalid_state unsupported_feature wrapper_fault)
},
%{
name: "poke",
request: %{
required: %{"signal" => "string", "value" => "encoded_value"},
optional: %{}
},
response: %{
required: %{
"signal" => "string",
"value" => "encoded_value",
"cycle" => "non_neg_integer"
},
optional: %{}
},
wrapper_operation: "Assign the top-level field, call eval(), and return the stored value.",
idempotency: :idempotent_at_same_cycle,
errors:
~w(unsupported_command invalid_request invalid_signal invalid_value invalid_state unsupported_feature wrapper_fault)
},
%{
name: "tick",
request: %{
required: %{},
optional: %{"clock" => "string", "cycles" => "positive_integer"},
defaults: %{"cycles" => 1}
},
response: %{
required: %{
"clock" => "string",
"cycles" => "positive_integer",
"cycle" => "non_neg_integer"
},
optional: %{}
},
wrapper_operation: "Advance complete clock cycles and increment the cycle counter.",
idempotency: :state_changing,
errors:
~w(unsupported_command invalid_request invalid_signal invalid_value invalid_state unsupported_feature wrapper_fault)
},
%{
name: "cycle",
request: %{
required: %{"cycles" => "positive_integer"},
optional: %{"clock" => "string"}
},
response: %{
required: %{
"clock" => "string",
"cycles" => "positive_integer",
"cycle" => "non_neg_integer"
},
optional: %{"time" => "non_neg_integer"}
},
wrapper_operation:
"Advance the requested number of complete clock cycles on the selected clock.",
idempotency: :state_changing,
errors:
~w(unsupported_command invalid_request invalid_signal invalid_value invalid_state unsupported_feature wrapper_fault)
},
%{
name: "peek",
request: %{required: %{"signal" => "string"}, optional: %{}},
response: %{
required: %{
"signal" => "string",
"value" => "encoded_value",
"cycle" => "non_neg_integer"
},
optional: %{}
},
wrapper_operation: "Read the top-level field after previous commands complete.",
idempotency: :read_only,
errors:
~w(unsupported_command invalid_request invalid_signal invalid_state unsupported_feature wrapper_fault)
},
%{
name: "finish?",
request: %{required: %{}, optional: %{}},
response: %{
required: %{
"finished" => "boolean",
"time" => "non_neg_integer",
"cycle" => "non_neg_integer"
},
optional: %{}
},
wrapper_operation:
"Query whether Verilator has requested finish without terminating the wrapper process.",
idempotency: :read_only,
errors: ~w(unsupported_command invalid_request invalid_state wrapper_fault)
},
%{
name: "shutdown",
request: %{required: %{}, optional: %{}},
response: %{required: %{"status" => "closing"}, optional: %{}},
wrapper_operation:
"Flush the response, call final(), release resources, and exit status 0.",
idempotency: :terminal,
errors: ~w(unsupported_command invalid_request invalid_state wrapper_fault)
}
]
@type command_name :: String.t()
@type request_id :: non_neg_integer()
@type encoded_value :: %{required(String.t()) => String.t() | pos_integer()}
@type envelope :: %{required(String.t()) => term()}
@type command_spec :: %{
required(:name) => command_name(),
required(:request) => map(),
required(:response) => map(),
required(:wrapper_operation) => String.t(),
required(:idempotency) => atom(),
required(:errors) => [String.t()]
}
@doc """
Returns the MVP command names in protocol-documentation order.
## Examples
iex> SvPortSim.Protocol.Command.command_names()
["metadata", "reset", "eval", "poke", "tick", "cycle", "peek", "finish?", "shutdown"]
"""
@spec command_names() :: [command_name()]
def command_names(), do: @command_names
@doc """
Returns the supported runtime error codes.
## Examples
iex> "invalid_signal" in SvPortSim.Protocol.Command.error_codes()
true
iex> "unsupported_feature" in SvPortSim.Protocol.Command.error_codes()
true
"""
@spec error_codes() :: [String.t()]
def error_codes(), do: @error_codes
@doc """
Returns command schema and wrapper-obligation metadata for every MVP command.
## Examples
iex> SvPortSim.Protocol.Command.command_specs() |> Enum.map(& &1.name)
["metadata", "reset", "eval", "poke", "tick", "cycle", "peek", "finish?", "shutdown"]
"""
@spec command_specs() :: [command_spec()]
def command_specs(), do: @command_specs
@doc """
Looks up one command specification.
## Examples
iex> {:ok, spec} = SvPortSim.Protocol.Command.command_spec("poke")
iex> spec.request.required["value"]
"encoded_value"
iex> SvPortSim.Protocol.Command.command_spec("step")
{:error, {:unsupported_command, "step"}}
"""
@spec command_spec(term()) :: {:ok, command_spec()} | {:error, term()}
def command_spec(command) when is_binary(command) do
case Enum.find(@command_specs, &(&1.name == command)) do
nil -> {:error, {:unsupported_command, command}}
spec -> {:ok, spec}
end
end
def command_spec(command), do: {:error, {:unsupported_command, command}}
@doc """
Looks up one command specification, raising `ArgumentError` on failure.
## Examples
iex> SvPortSim.Protocol.Command.command_spec!("shutdown").idempotency
:terminal
"""
@spec command_spec!(term()) :: command_spec()
def command_spec!(command) do
case command_spec(command) do
{:ok, spec} -> spec
{:error, reason} -> raise ArgumentError, message: inspect(reason)
end
end
@doc """
Returns whether `command` is an MVP command name.
## Examples
iex> SvPortSim.Protocol.Command.supported?("peek")
true
iex> SvPortSim.Protocol.Command.supported?("step")
false
"""
@spec supported?(term()) :: boolean()
def supported?(command), do: match?({:ok, _spec}, command_spec(command))
@doc """
Builds a validated command request envelope.
## Examples
iex> {:ok, request} = SvPortSim.Protocol.Command.request("peek", 1, %{"signal" => "count"})
iex> {request["v"], request["id"], request["kind"], request["op"]}
{1, 1, "request", "peek"}
iex> SvPortSim.Protocol.Command.request("peek", -1, %{"signal" => "count"})
{:error, {:invalid_request_id, -1}}
"""
@spec request(term(), term(), map()) :: {:ok, envelope()} | {:error, term()}
def request(command, id, body \\ %{})
def request(command, id, body)
when is_binary(command) and is_integer(id) and id >= 0 and is_map(body) do
with :ok <- validate_request_body(command, body) do
{:ok,
%{
"v" => Protocol.version(),
"id" => id,
"kind" => "request",
"op" => command,
"body" => body
}}
end
end
def request(_command, id, _body) when not (is_integer(id) and id >= 0) do
{:error, {:invalid_request_id, id}}
end
def request(command, _id, body) when not is_binary(command) or not is_map(body) do
{:error, {:invalid_request_arguments, command, body}}
end
@doc """
Builds a validated successful response envelope from a request envelope.
## Examples
iex> {:ok, request} = SvPortSim.Protocol.Command.request("shutdown", 9)
iex> {:ok, response} = SvPortSim.Protocol.Command.ok_response(request, %{"status" => "closing"})
iex> {response["id"], response["kind"], response["op"]}
{9, "response", "shutdown"}
"""
@spec ok_response(envelope(), map()) :: {:ok, envelope()} | {:error, term()}
def ok_response(request, body \\ %{})
def ok_response(%{"id" => id, "op" => command}, body) do
ok_response(id, command, body)
end
def ok_response(%{id: id, op: command}, body) do
ok_response(id, command, body)
end
def ok_response(request, _body), do: {:error, {:invalid_request, request}}
@doc """
Builds a validated successful response envelope from an id, command, and body.
## Examples
iex> {:ok, response} = SvPortSim.Protocol.Command.ok_response(1, "metadata", %{"top" => "Counter", "signals" => [], "cycle" => 0})
iex> {response["kind"], response["op"]}
{"response", "metadata"}
"""
@spec ok_response(term(), term(), map()) :: {:ok, envelope()} | {:error, term()}
def ok_response(id, command, body)
when is_integer(id) and id >= 0 and is_binary(command) and is_map(body) do
with :ok <- validate_response_body(command, body) do
{:ok,
%{
"v" => Protocol.version(),
"id" => id,
"kind" => "response",
"op" => command,
"body" => body
}}
end
end
def ok_response(id, command, body) do
{:error, {:invalid_response_arguments, id, command, body}}
end
@doc """
Builds a validated error response envelope from a request envelope.
## Examples
iex> {:ok, request} = SvPortSim.Protocol.Command.request("peek", 10, %{"signal" => "missing"})
iex> {:ok, error} = SvPortSim.Protocol.Command.error_response(request, "invalid_signal", "unknown signal", %{"signal" => "missing"})
iex> {error["id"], error["kind"], error["body"]["code"]}
{10, "error", "invalid_signal"}
"""
@spec error_response(envelope() | request_id(), String.t(), String.t(), map()) ::
{:ok, envelope()} | {:error, term()}
def error_response(request_or_id, code, message, details \\ %{})
def error_response(%{"id" => id, "op" => command}, code, message, details) do
error_response(id, command, code, message, details)
end
def error_response(%{id: id, op: command}, code, message, details) do
error_response(id, command, code, message, details)
end
def error_response(id, code, message, details) when is_integer(id) do
error_response(id, "unknown", code, message, details)
end
def error_response(request, _code, _message, _details),
do: {:error, {:invalid_request, request}}
@doc """
Builds a validated error response envelope from an id, command, error code, message, and details map.
The default `fatal` value is derived from `SvPortSim.Protocol.fatal_runtime_error?/1`. Pass
`fatal: true` or `fatal: false` to override the default.
## Examples
iex> {:ok, error} = SvPortSim.Protocol.Command.error_response(1, "step", "unsupported_command", "unsupported command", %{})
iex> {error["op"], error["body"]["fatal"]}
{"step", false}
iex> {:ok, error} = SvPortSim.Protocol.Command.error_response(1, "tick", "wrapper_fault", "segmentation fault", %{})
iex> {error["op"], error["body"]["fatal"]}
{"tick", true}
"""
@spec error_response(term(), term(), term(), term(), term()) ::
{:ok, envelope()} | {:error, term()}
def error_response(id, command, code, message, details) do
error_response(id, command, code, message, details, [])
end
@doc """
Builds a validated error response envelope and explicitly configures `fatal`.
## Examples
iex> {:ok, error} = SvPortSim.Protocol.Command.error_response(1, "tick", "invalid_state", "stopped", %{}, fatal: true)
iex> error["body"]["fatal"]
true
"""
@spec error_response(term(), term(), term(), term(), term(), keyword()) ::
{:ok, envelope()} | {:error, term()}
def error_response(id, command, code, message, details, opts)
when is_integer(id) and id >= 0 and is_binary(command) and byte_size(command) > 0 and
is_binary(code) and is_binary(message) and is_map(details) and is_list(opts) do
with {:ok, body} <- Protocol.error_body(code, message, details, opts),
:ok <- validate_error_body(body) do
{:ok,
%{
"v" => Protocol.version(),
"id" => id,
"kind" => "error",
"op" => command,
"body" => body
}}
end
end
def error_response(id, command, code, message, details, opts) do
{:error, {:invalid_error_arguments, id, command, code, message, details, opts}}
end
@doc """
Validates a request envelope and its command body.
## Examples
iex> {:ok, request} = SvPortSim.Protocol.Command.request("tick", 1, %{"cycles" => 2})
iex> SvPortSim.Protocol.Command.validate_request(request)
:ok
iex> SvPortSim.Protocol.Command.validate_request(%{"v" => 1, "id" => 1, "kind" => "request", "op" => "step", "body" => %{}})
{:error, {:unsupported_command, "step"}}
"""
@spec validate_request(term()) :: :ok | {:error, term()}
def validate_request(%{} = message) do
with :ok <- Protocol.validate_envelope(message),
:ok <- validate_kind(message, "request") do
validate_request_body(message["op"], message["body"])
end
end
def validate_request(message), do: {:error, {:invalid_request, message}}
@doc """
Validates a successful response envelope and its command body.
## Examples
iex> {:ok, response} = SvPortSim.Protocol.Command.ok_response(1, "shutdown", %{"status" => "closing"})
iex> SvPortSim.Protocol.Command.validate_response(response)
:ok
"""
@spec validate_response(term()) :: :ok | {:error, term()}
def validate_response(%{} = message) do
with :ok <- Protocol.validate_envelope(message),
:ok <- validate_kind(message, "response") do
validate_response_body(message["op"], message["body"])
end
end
def validate_response(message), do: {:error, {:invalid_response, message}}
@doc """
Validates an error response envelope and error body.
Error responses may use an unsupported command name in `op`; this lets the wrapper explicitly
answer a well-formed request whose operation is unknown.
## Examples
iex> {:ok, error} = SvPortSim.Protocol.Command.error_response(1, "step", "unsupported_command", "unsupported command", %{})
iex> SvPortSim.Protocol.Command.validate_error(error)
:ok
"""
@spec validate_error(term()) :: :ok | {:error, term()}
def validate_error(%{} = message) do
with :ok <- Protocol.validate_envelope(message),
:ok <- validate_kind(message, "error") do
validate_error_body(message["body"])
end
end
def validate_error(message), do: {:error, {:invalid_error, message}}
@doc """
Validates any command-layer envelope by dispatching on `kind`.
## Examples
iex> {:ok, request} = SvPortSim.Protocol.Command.request("metadata", 1)
iex> SvPortSim.Protocol.Command.validate_message(request)
:ok
"""
@spec validate_message(term()) :: :ok | {:error, term()}
def validate_message(%{"kind" => "request"} = message), do: validate_request(message)
def validate_message(%{"kind" => "response"} = message), do: validate_response(message)
def validate_message(%{"kind" => "error"} = message), do: validate_error(message)
def validate_message(message), do: {:error, {:invalid_message, message}}
@doc """
Validates a request body for a command name.
## Examples
iex> SvPortSim.Protocol.Command.validate_request_body("poke", %{"signal" => "enable", "value" => %{"bits" => "1", "width" => 1}})
:ok
iex> SvPortSim.Protocol.Command.validate_request_body("poke", %{"signal" => "enable"})
{:error, {:missing_field, "poke", "value"}}
"""
@spec validate_request_body(term(), term()) :: :ok | {:error, term()}
def validate_request_body(command, body)
def validate_request_body("metadata", body), do: validate_empty_body("metadata", body)
def validate_request_body("reset", %{} = body) do
with :ok <- validate_allowed_keys("reset", body, ~w(cycles reset)),
:ok <- validate_optional_positive_integer("reset", body, "cycles") do
validate_optional_non_empty_string("reset", body, "reset")
end
end
def validate_request_body("eval", body), do: validate_empty_body("eval", body)
def validate_request_body("poke", %{} = body) do
with :ok <- validate_required_non_empty_string("poke", body, "signal"),
:ok <- validate_required_encoded_value("poke", body, "value") do
validate_allowed_keys("poke", body, ~w(signal value))
end
end
def validate_request_body("tick", %{} = body) do
with :ok <- validate_allowed_keys("tick", body, ~w(clock cycles)),
:ok <- validate_optional_non_empty_string("tick", body, "clock") do
validate_optional_positive_integer("tick", body, "cycles")
end
end
def validate_request_body("cycle", %{} = body) do
with :ok <- validate_required_positive_integer("cycle", body, "cycles"),
:ok <- validate_optional_non_empty_string("cycle", body, "clock") do
validate_allowed_keys("cycle", body, ~w(clock cycles))
end
end
def validate_request_body("peek", %{} = body) do
with :ok <- validate_required_non_empty_string("peek", body, "signal") do
validate_allowed_keys("peek", body, ~w(signal))
end
end
def validate_request_body("finish?", body), do: validate_empty_body("finish?", body)
def validate_request_body("shutdown", body), do: validate_empty_body("shutdown", body)
def validate_request_body(command, %{}) when is_binary(command) do
{:error, {:unsupported_command, command}}
end
def validate_request_body(command, body), do: {:error, {:invalid_body, command, body}}
@doc """
Validates a successful response body for a command name.
## Examples
iex> SvPortSim.Protocol.Command.validate_response_body("peek", %{"signal" => "count", "value" => %{"bits" => "0001", "width" => 4}, "cycle" => 3})
:ok
iex> SvPortSim.Protocol.Command.validate_response_body("shutdown", %{"status" => "closed"})
{:error, {:invalid_field, "shutdown", "status", "closed"}}
"""
@spec validate_response_body(term(), term()) :: :ok | {:error, term()}
def validate_response_body(command, body)
def validate_response_body("metadata", %{} = body) do
with :ok <- validate_required_non_empty_string("metadata", body, "top"),
:ok <- validate_required_list("metadata", body, "signals"),
:ok <- validate_required_non_negative_integer("metadata", body, "cycle"),
:ok <- validate_optional_map("metadata", body, "protocol") do
validate_allowed_keys("metadata", body, ~w(top signals cycle protocol))
end
end
def validate_response_body("reset", %{} = body) do
with :ok <- validate_required_non_negative_integer("reset", body, "cycle"),
:ok <- validate_required_map("reset", body, "reset"),
:ok <- validate_required_positive_integer("reset", body["reset"], "cycles"),
:ok <- validate_allowed_keys("reset", body, ~w(cycle reset)),
:ok <- validate_allowed_keys("reset", body["reset"], ~w(cycles signal)) do
validate_optional_non_empty_string("reset", body["reset"], "signal")
end
end
def validate_response_body("eval", %{} = body) do
with :ok <- validate_required_non_negative_integer("eval", body, "time"),
:ok <- validate_required_non_negative_integer("eval", body, "cycle") do
validate_allowed_keys("eval", body, ~w(time cycle))
end
end
def validate_response_body("poke", %{} = body),
do: validate_signal_value_cycle_body("poke", body)
def validate_response_body("tick", %{} = body) do
with :ok <- validate_required_non_empty_string("tick", body, "clock"),
:ok <- validate_required_positive_integer("tick", body, "cycles"),
:ok <- validate_required_non_negative_integer("tick", body, "cycle") do
validate_allowed_keys("tick", body, ~w(clock cycles cycle))
end
end
def validate_response_body("cycle", %{} = body) do
with :ok <- validate_required_non_empty_string("cycle", body, "clock"),
:ok <- validate_required_positive_integer("cycle", body, "cycles"),
:ok <- validate_required_non_negative_integer("cycle", body, "cycle"),
:ok <- validate_optional_non_negative_integer("cycle", body, "time") do
validate_allowed_keys("cycle", body, ~w(clock cycles cycle time))
end
end
def validate_response_body("peek", %{} = body),
do: validate_signal_value_cycle_body("peek", body)
def validate_response_body("finish?", %{} = body) do
with :ok <- validate_required_boolean("finish?", body, "finished"),
:ok <- validate_required_non_negative_integer("finish?", body, "time"),
:ok <- validate_required_non_negative_integer("finish?", body, "cycle") do
validate_allowed_keys("finish?", body, ~w(finished time cycle))
end
end
def validate_response_body("shutdown", %{"status" => "closing"} = body) do
validate_allowed_keys("shutdown", body, ~w(status))
end
def validate_response_body("shutdown", %{"status" => status}) do
{:error, {:invalid_field, "shutdown", "status", status}}
end
def validate_response_body(command, %{}) when is_binary(command) do
{:error, {:unsupported_command, command}}
end
def validate_response_body(command, body), do: {:error, {:invalid_body, command, body}}
@doc """
Validates the body of a command-layer error envelope.
## Examples
iex> SvPortSim.Protocol.Command.validate_error_body(%{"code" => "invalid_request", "message" => "missing field", "details" => %{}, "fatal" => false})
:ok
iex> SvPortSim.Protocol.Command.validate_error_body(%{"code" => "invalid_request", "message" => "missing field"})
:ok
iex> SvPortSim.Protocol.Command.validate_error_body(%{"code" => "unsupported_feature", "message" => "structs are not supported", "details" => %{"feature" => "struct"}, "fatal" => false})
:ok
"""
@spec validate_error_body(term()) :: :ok | {:error, term()}
def validate_error_body(body), do: Protocol.validate_error_body(body)
defp validate_required_boolean(command, body, field) do
case Map.fetch(body, field) do
{:ok, value} when is_boolean(value) -> :ok
{:ok, value} -> {:error, {:invalid_field, command, field, value}}
:error -> {:error, {:missing_field, command, field}}
end
end
defp validate_optional_non_negative_integer(command, body, field) do
case Map.fetch(body, field) do
{:ok, value} when is_integer(value) and value >= 0 -> :ok
{:ok, value} -> {:error, {:invalid_field, command, field, value}}
:error -> :ok
end
end
defp validate_kind(%{"kind" => actual}, expected) when actual == expected, do: :ok
defp validate_kind(%{"kind" => actual}, expected),
do: {:error, {:invalid_kind, actual, expected}}
defp validate_kind(message, expected), do: {:error, {:missing_field, expected, "kind", message}}
defp validate_empty_body(command, %{} = body) when map_size(body) == 0 do
case command_spec(command) do
{:ok, _spec} -> :ok
{:error, _reason} = error -> error
end
end
defp validate_empty_body(command, %{} = body) do
case command_spec(command) do
{:ok, _spec} -> validate_allowed_keys(command, body, [])
{:error, _reason} = error -> error
end
end
defp validate_empty_body(command, body), do: {:error, {:invalid_body, command, body}}
defp validate_signal_value_cycle_body(command, body) do
with :ok <- validate_required_non_empty_string(command, body, "signal"),
:ok <- validate_required_encoded_value(command, body, "value"),
:ok <- validate_required_non_negative_integer(command, body, "cycle") do
validate_allowed_keys(command, body, ~w(signal value cycle))
end
end
defp validate_allowed_keys(command, %{} = body, allowed_keys) do
case Map.keys(body) -- allowed_keys do
[] -> :ok
[field | _rest] -> {:error, {:unknown_field, command, field}}
end
end
defp validate_required_non_empty_string(command, body, field) do
case Map.fetch(body, field) do
{:ok, value} when is_binary(value) and byte_size(value) > 0 -> :ok
{:ok, value} -> {:error, {:invalid_field, command, field, value}}
:error -> {:error, {:missing_field, command, field}}
end
end
defp validate_optional_non_empty_string(command, body, field) do
case Map.fetch(body, field) do
{:ok, value} when is_binary(value) and byte_size(value) > 0 -> :ok
{:ok, value} -> {:error, {:invalid_field, command, field, value}}
:error -> :ok
end
end
defp validate_required_positive_integer(command, body, field) do
case Map.fetch(body, field) do
{:ok, value} when is_integer(value) and value > 0 -> :ok
{:ok, value} -> {:error, {:invalid_field, command, field, value}}
:error -> {:error, {:missing_field, command, field}}
end
end
defp validate_optional_positive_integer(command, body, field) do
case Map.fetch(body, field) do
{:ok, value} when is_integer(value) and value > 0 -> :ok
{:ok, value} -> {:error, {:invalid_field, command, field, value}}
:error -> :ok
end
end
defp validate_required_non_negative_integer(command, body, field) do
case Map.fetch(body, field) do
{:ok, value} when is_integer(value) and value >= 0 -> :ok
{:ok, value} -> {:error, {:invalid_field, command, field, value}}
:error -> {:error, {:missing_field, command, field}}
end
end
defp validate_required_list(command, body, field) do
case Map.fetch(body, field) do
{:ok, value} when is_list(value) -> :ok
{:ok, value} -> {:error, {:invalid_field, command, field, value}}
:error -> {:error, {:missing_field, command, field}}
end
end
defp validate_required_map(command, body, field) do
case Map.fetch(body, field) do
{:ok, value} when is_map(value) -> :ok
{:ok, value} -> {:error, {:invalid_field, command, field, value}}
:error -> {:error, {:missing_field, command, field}}
end
end
defp validate_optional_map(command, body, field) do
case Map.fetch(body, field) do
{:ok, value} when is_map(value) -> :ok
{:ok, value} -> {:error, {:invalid_field, command, field, value}}
:error -> :ok
end
end
defp validate_required_encoded_value(command, body, field) do
case Map.fetch(body, field) do
{:ok, value} -> validate_encoded_value(command, field, value)
:error -> {:error, {:missing_field, command, field}}
end
end
defp validate_encoded_value(command, field, %{"bits" => bits, "width" => width}) do
validate_encoded_bits(command, field, bits, width)
end
defp validate_encoded_value(command, field, %{bits: bits, width: width}) do
validate_encoded_bits(command, field, bits, width)
end
defp validate_encoded_value(command, field, value) do
{:error, {:invalid_field, command, field, value}}
end
defp validate_encoded_bits(command, field, bits, width)
when is_binary(bits) and is_integer(width) and width > 0 do
cond do
String.length(bits) != width ->
{:error, {:invalid_encoded_width, command, field, bits, width}}
not encoded_bit_string?(bits) ->
{:error, {:invalid_encoded_bits, command, field, bits}}
true ->
:ok
end
end
defp validate_encoded_bits(command, field, bits, width) do
{:error, {:invalid_encoded_value, command, field, bits, width}}
end
defp encoded_bit_string?(bits) do
bits
|> String.graphemes()
|> Enum.all?(&(&1 in ~w(0 1 x z)))
end
end