Ferrowl is a TUI application, written in Rust, to simulate both Modbus (Client and Server) and OCPP (Charging Station / CSMS) devices. Create configurations on the fly, save and load configurations or sessions to set up multiple Modbus or OCPP instances side by side. The aim is to provide a technical but intuitive interface that can run on any device without an available GUI environment.
If you prefer a GUI application, this tool is not the right choice. For Modbus, refer to a GUI application like QModbus.
Warning
Prior to v0.4.0 the application was based on a draft implementation. Over time additional features were added but messed up the architecture and made it difficult to add new views, dialogs and support of multiple instances. Thus, starting with v0.4.0 the application got a full rewrite. This also affects the configuration files and their management. You can migrate configuration files created by versions prior to v0.4.0 using the migrate subcommand - e.g. ferrowl migrate -i old-config.json -o new-config.json (supports JSON and TOML as input and output).
Provide a CLI application to simulate Modbus Servers and Clients as well as OCPP Charging Stations and Central Systems (CSMS, OCPP 1.6, 2.0.1 and 2.1), visualize the states of all registers and charging-station fields, make manipulation available and provide script based simulation capabilities - e.g. utilize the tool to simulate EVSEs over Modbus or OCPP.
The project is organized as a Cargo workspace and builds the ferrowl binary.
The crate dependency graph, each crate's responsibility, and the runtime data-flow
and concurrency model are documented in ARCHITECTURE.md.
Authoritative, per-capability behavior lives under docs/specs/.
Alongside Modbus, Ferrowl simulates OCPP charging infrastructure over JSON-on-WebSocket. Both protocol versions and both roles are supported:
- Versions: OCPP 1.6, 2.0.1, and 2.1.
- Roles: Charging Station (client, connects out to a CSMS) and Central System / CSMS (server, accepts incoming stations and tracks each connection).
Supported capabilities (grouped by area):
- Transactions & metering — start/stop transactions,
MeterValues, live connector state (status, phases, voltage, per-phase current, power, energy). - Reservations —
ReserveNow/CancelReservation, per connector. - Authorization — RFID accept-lists, both station-wide and per-connector, plus local-list management.
- Smart charging — charging profiles and per-purpose charge limits, including stack-level reject.
- Remote control — remote (1.6) / requested (2.0.1, 2.1) start & stop, availability changes, reset, firmware update and diagnostics.
- OCPP 2.0.1 extras — variable get/set and monitoring, display messages, certificate management, and the EVSE/connector object model.
- OCPP 2.1 extras — DER control (get/clear/set), tariffs, priority charging, settlement and web-payment notifications, battery swapping. Most 2.1-only actions get a typed send dialog; a few whose payload is an inherently nested/repeated structure (battery data, certificate-chain-status requests, DER-control reports, full tariff objects, periodic-event-stream setup, dynamic-schedule updates) stay on the raw-JSON editor.
In the TUI each OCPP module shows a connector/station table, a scope-filtered action list with per-version send dialogs (typed value editors plus a raw-JSON mode), and a capped message log that can be mirrored to a file via :log. Simulation behaviour is scripted in Lua for both roles — see Lua Support.
This repository provides an updated Nightly build - available on the Release page. Prebuilt executables are provided for Unix and Windows.
This project is written in Rust, thus you will have to install the Rust toolchain to compile it. Just follow the instructions on rustup.rs to set up the environment. Afterwards you are able to compile this project from source using the following command.
cargo build --releaseAlternatively, you can also run it directly using the following command. Please refer to --help for all available runtime options and to the Release page for prebuilt binaries.
# Build and run
cargo run --release
# Or with the application already built
ferrowl
# Or in demo mode
ferrowl --demo
# Or with an existing session file
ferrowl --session session.toml
# Or with a device configuration only (starts a TCP client polling 127.0.0.1:5020,
# matching the --demo server; use --module for a custom endpoint or role)
ferrowl --device device.tomlIf started without any additional parameters, the module setup dialog is shown. After the module is created, you can add registers using the :add command. To create an OCPP module instead, choose the OCPP type in the :new setup dialog.
The bundled session.toml wires up a CSMS plus a Charging Station pair (csms-demo.toml / cs-demo.toml), so ferrowl --session session.toml brings both OCPP modules online at once.
Important
You can use VIM-like table navigation or alternatively the arrow keys. You can exit using the :qa command. Typing : will automatically switch to command mode. See the shown overlay for all available commands.
ferrowl run starts the same modules the TUI would, without ever drawing a screen — useful for
CI pipelines and scripted smoke tests. It accepts the same --session/--module specs as the
TUI, plus an ad-hoc --ocpp flag, and streams every module's log to stdout as
[<timestamp>] <module-name> | <line>.
# Run a session file for 30 seconds, mirroring the log to a file too
ferrowl run --session session.toml --duration 30 --log-file run.log
# Run an ad-hoc Modbus server until Ctrl-C
ferrowl run --module name=evse-1,device=configs/evse.toml,transport=tcp,ip=0.0.0.0,port=502,role=server
# Fail fast if a Lua sim script raises during the run
ferrowl run --session session.toml --duration 30 --exit-on-errorExit codes:
| Code | Meaning |
|---|---|
0 |
Ran clean — --duration elapsed, or Ctrl-C was received, with no --exit-on-error hit. |
1 |
Startup failure — a module's device config failed to load, or start reported an error. |
2 |
--exit-on-error was set and a drained log line looked like a Lua script error (matched the [sim] prefix those errors are logged under). |
On any exit, every module that started is stopped first (best-effort).
| Command | Description |
|---|---|
:q | :quit |
Quit tab / Close active module |
:qa | :qall |
Close all tabs / Exit application |
:e | :edit |
Edit current module |
:n | :new |
Create new module |
:l | :load [PATH] |
Load device configuration |
:a | :add |
Add new register to module |
:start |
Start module execution |
:stop |
Stop module execution |
:restart |
Restart module execution |
:set <reg> <val> |
Write register value |
:s | :save | :w | :write [PATH] |
Save session |
:wd | :write-device [PATH] |
Save device configuration |
:log <FILE>|clear |
Set log output file for the active tab (:log clear clears the ring log) |
:script |
Manage Lua scripts (create, edit, toggle, delete) |
:session |
Session scripts + sim interval |
:reload |
Reload device configuration |
:compact |
Toggle compact table mode |
:order [col] [asc|desc] |
Sort table by column |
| Keybind | Description |
|---|---|
Enter |
Open/Confirm dialog |
Escape |
Cancel dialogs |
k | Up |
Select previous table entry |
h | Left |
Scroll left in table view |
l | Right |
Scroll right in table view |
j | Down |
Select next table entry |
Tab |
Focus next dialog element |
Shift-Tab |
Focus previous dialog element |
Space |
Click focused button |
G |
Move to bottom of table |
g |
Move to top of table |
0 |
Move to left edge of table |
$ |
Move to right edge of table |
z |
Toggle compact table mode |
ctrl + t, l |
Switch to next tab |
ctrl + t, h |
Switch to previous tab |
ctrl + d |
Clear input field |
ctrl + f |
Accept autofill in input field |
All screenshots below are generated automatically by scripts/screenshots.py,
which drives ferrowl --demo headlessly in tmux, walks each view/dialog by keystroke, and renders the
captured terminal content to PNG — run it after UI changes to refresh them.
The session configuration can be saved using :write and contains the module configuration consisting of the name, path to the device configuration, the role and endpoint information. Timings (timeout_ms, delay_ms, interval_ms) are part of the device configuration, not the session.
[[modules]]
name = "evse-1"
device = "configs/evse.toml"
role = "server"
[modules.endpoint]
transport = "tcp"
ip = "127.0.0.1"
port = 5020Besides TCP, a serial RTU endpoint is supported. parity (even, odd or none, case-insensitive), data_bits and stop_bits are optional; baud_rate defaults to 19200.
[modules.endpoint]
transport = "rtu"
path = "/dev/ttyUSB0"
baud_rate = 19200
parity = "none"
data_bits = 8
stop_bits = 1An OCPP module session entry is tagged type = "ocpp" and carries only the name, the
device-config path and the websocket endpoint (protocol is ws or wss); the OCPP version,
role, timeout and Lua scripts live in the referenced device file.
[[modules]]
type = "ocpp"
name = "cs-1"
device = "configs/cs.toml"
protocol = "ws"
ip = "127.0.0.1"
port = 9000The device configuration can be saved using :write-device and contains the register information of the device and all necessary timings.
[definitions.setpoint]
slave_id = 1
read_code = 4 # 4 = holding register
address = 0
type = "U16"
access = "ReadWrite"
description = "charge setpoint (W)"
default = 0 # start at zero watts on every load
[definitions.power]
slave_id = 1
read_code = 4
address = 1
type = "U16"
access = "ReadWrite"
description = "active power (W)"
default = 0
[definitions.state]
slave_id = 1
read_code = 4
address = 2
type = "I16"
access = "ReadWrite"
description = "charge state"
default = 0 # start in the "waiting" state
values = [
{ name = "waiting", value = 0 },
{ name = "charging", value = 2 },
{ name = "error", value = -1 },
]
# Global Lua scripts, run every simulation cycle while enabled (see :script).
[[scripts]]
name = "mirror setpoint"
code = """
C_Register:Set("power", C_Register:Get("setpoint"))
"""
enabled = trueImportant
It's possible to create virtual registers using virtual = true to store values not accessible over Modbus.
| Field | Default | Description |
|---|---|---|
type |
(required) | Value encoding: U8…U128, I8…I128, F32, F64, Ascii. |
slave_id |
0 |
Modbus unit / slave id. |
read_code |
3 |
Read function code: 1=Coil, 2=DiscreteInput, 3=InputRegister, 4=HoldingRegister. |
address |
(none) | Start address. Omit (or set virtual = true) for a virtual register. |
virtual |
false |
Hold the value locally instead of mapping it to a Modbus address. |
access |
ReadWrite |
ReadOnly, WriteOnly or ReadWrite. |
endian |
Big |
Byte order: Big or Little. |
resolution |
1.0 |
Scaling factor applied to the raw value for display; edit dialogs and :set take the unscaled raw value. |
bitmask |
(none) | Bit-field mask for integer types, as a hex ("0xFF00") or decimal string; the shift is derived from the mask's trailing zeros. Ignored for float and ASCII types. |
length |
1 |
ASCII width in registers (ignored for numeric types). |
alignment |
Left |
ASCII alignment: Left or Right. |
values |
[] |
Named values for selection-style registers. |
default |
(none) | Default value written to memory on startup / configuration load. |
update |
(none) | Deprecated. Legacy per-register Lua snippet; migrated on load into the global [[scripts]] list (named after the register) and never written back. |
description |
"" |
Free-text description. |
Alongside definitions, a device file may carry a version (stamped automatically on save), default timings, and explicit client read ranges:
description = "EVSE charge point"
timeout_ms = 2000 # per-request timeout
delay_ms = 1000 # delay before the first read
interval_ms = 1000 # poll interval
reconnect = true # client role: reconnect with backoff after a lost/refused
# connection (default true; false = stop on the first error)
# Client read batching per function code. Each value is a comma-separated list of inclusive
# address ranges (a bare "5" is the single address 5). When unset, contiguous registers are
# auto-merged into requests.
[read_ranges]
holding = "0-100,140-160"
input = "0-10"
# coils / discrete are also availableTiming precedence is device → built-in defaults (3000/1000/1000 ms).
An OCPP device file (saved with :write-device) describes the charge point: its OCPP version,
role, reply timeout and the Lua simulation scripts. Endpoint (ip/port/protocol) is per-instance and
lives in the session, not here.
version = "0.4.4" # ferrowl version, stamped on save
ocpp_version = "1.6" # or "2.0.1" or "2.1"
role = "client" # client = charging station, server = management system
timeout_ms = 30000 # awaited-reply timeout
[[scripts]]
name = "ramp"
enabled = true
code = "C_OCPP:Set(\"Power\", C_OCPP:Get(\"Power\") + 100)"
# Optional OCPP security profiles. All fields are optional and the whole
# section may be omitted (plain ws, no auth).
[security]
username = "cp001" # profile 1: HTTP Basic Auth on the upgrade request
password = "secret" # (CS sends; a CSMS with credentials rejects mismatches with 401)
ca_file = "certs/ca.pem" # profile 2, CS: extra trust anchor for a self-signed CSMS cert
cert_file = "certs/csms.pem" # profile 2, CSMS: TLS listener certificate chain
key_file = "certs/csms.key" # profile 2, CSMS: TLS private key
client_cert_file = "certs/cs.pem" # profile 3, CS: client certificate for mutual TLS
client_key_file = "certs/cs.key" # profile 3, CS: matching private key
client_ca_file = "certs/ca.pem" # profile 3, CSMS: CA that client certs must chain to
require_client_cert = false # profile 3, CSMS: reject connections without a valid client cert
self_signed = true # server: ephemeral self-signed TLS below the TLS level
insecure_skip_verify = true # client: accept any server certificate (testing only)Use protocol = "wss" in the session entry together with the TLS fields. A CSMS (server role)
wss instance below the TLS level (None/Basic Auth) does not fall back to plain TCP: it
generates an ephemeral self-signed certificate in memory at each start (never written to
disk) and terminates TLS with it — set self_signed = true, or just pick anything below TLS in
the :new/:edit setup dialog, which sets it for you. Because the identity changes on every
start, a connecting CS cannot pin it via ca_file in advance; either supply real cert_file/
key_file (the TLS/mTLS levels) or have the CS set insecure_skip_verify = true, which accepts
any server certificate without authenticating it. The connection is still TLS-encrypted, but the
peer's identity is not checked at all — this is a test-rig convenience only; never enable it
against a production CSMS, since it makes the connection vulnerable to a man-in-the-middle.
As an additional feature, the tool also includes a Lua runtime to execute custom scripts that drive a simulation. For Modbus modules scripts are attached to the device config as a global, toggleable list managed from the :script dialog (legacy per-register update snippets are migrated into it on load); all enabled scripts run each simulation cycle, interacting with the registers through C_Register and able to print to the module log via C_Log. For OCPP modules — in both the Charging Station (client) and CSMS (server) roles — scripts are attached to the device config and managed from the Lua Scripts dialog (the button under the state table); all enabled scripts run about once per second and interact with the OCPP state and actions through C_OCPP, and may print to the module log via C_Log. Besides the standard Lua libraries, the exposed modules are C_Time, C_Log and C_Test (all module types), C_Register (Modbus only), and C_OCPP (OCPP only).
Session-level Lua scripts run in their own context with access to every module in the session via the C_Module API, allowing cross-module simulation and orchestration. Edited in the :session dialog (script list + sim interval pane + live log tail), they are stored in the session file under [[scripts]] (array of {name, code, enabled}) and interval (float seconds, default 1.0). Like module-level scripts, session scripts run if and only if at least one script is enabled — the Lua runtime starts when toggling any script on, stops when disabling all, and restarts (globals reset) on any code edit.
All module names in a session are auto-deduplicated on load or rename (duplicates get suffixed like "name (2)"), ensuring C_Module can reliably address each one by name.
In headless mode (ferrowl run), session scripts execute the same way; their output appears prefixed with the session log source, and [sim] errors trigger --exit-on-error (exit code 2).
Method: C_Module:List()
Return: Sorted array of current module names.
Method: C_Module:Get(name)
Arguments:
Name: name
Type: String
Description: Module name.
Return: ModuleHandle for the named module, or raises if the module is unknown.
A ModuleHandle re-resolves its target by name on every method call. If a module is removed after Get, subsequent calls raise "unknown module" instead of returning stale data.
Method: ModuleHandle:Type()
Return: "modbus" or "ocpp".
Method: ModuleHandle:Role()
Return: "client" or "server".
Method: ModuleHandle:Register()
Return: `C_Register`-shaped accessor for modbus modules; raises for others.
Method: ModuleHandle:OCPP()
Return: Role-specific `C_OCPP`-shaped accessor for ocpp modules; raises for others.
local src = C_Module:Get("meter")
local dst = C_Module:Get("charger")
local val = src:Register():Get("voltage")
dst:Register():Set("voltage", val)Method: C_Time:Get()
Arguments: None
Return: Time in seconds since startup.
Method: C_Time:GetMs()
Arguments: None
Return: Time in milliseconds since startup.
Method: C_Log:Info(message)
Method: C_Log:Warn(message)
Method: C_Log:Error(message)
Arguments:
Name: message
Type: String
Description: A line to append to the module's log (the on-screen log
pane, and the file sink when `:log <file>` is active) at
the corresponding level.
Return: nil
Assertion helpers for scripted checks, mainly with the headless mode's
--exit-on-error (a failed assertion raises a Lua error, which the sim loop logs
with the [sim] prefix the headless runner watches for).
Method: C_Test:Assert(cond, msg)
Arguments:
Name: cond
Type: Any
Description: The condition to check; `nil` and `false` fail, everything
else passes (Lua truthiness).
Name: msg
Type: String
Description: Message logged as "assertion failed: <msg>" when cond fails.
Return: nil (raises a Lua error on failure)
Method: C_Test:Fail(msg)
Arguments:
Name: msg
Type: String
Description: Message logged as "assertion failed: <msg>".
Return: never returns normally (always raises)
Method: C_Register:Get(name)
Arguments:
Name: name
Type: String
Description: Name of the register as defined in the configuration.
Return: Value of the register, typed to match it: a number for integer and
floating-point registers, a string for strings and a boolean for booleans.
Method: C_Register:Set(name, value)
Arguments:
Name: name
Type: String
Description: Name of the register as defined in the configuration.
Name: value
Type: String | bool | integer | float
Description: Value to set for the specified register
Return: nil
Exposed to the Lua scripts of an OCPP module, in both the charging-station (client) and CSMS
(server) roles. All loaded, enabled scripts run about once per second. C_Time is also available;
C_Register is not (it is Modbus-only).
The module has a flat surface plus role-specific scope accessors:
- Client — bare
Get/Set/<Action>address the charging station itself;Connector(id)returns an accessor scoped to one connector with the sameGet/Set/<Action>surface. - Server (CSMS) —
GetChargingStations()andGetConnectors(cs)enumerate the connected stations and their connectors;ChargingStation(cs)andConnector(cs, id)return accessors scoped to one station or one of its connectors.
Get/Set read and write the addressed scope's state by name. Supported names (compact forms of
the state-table labels):
ConnectorId, Phases, Voltage, Current (= CurrentL1), CurrentL1, CurrentL2, CurrentL3,
Power, TotalEnergy, SessionEnergy, Status, Rfid, Model, Vendor
OCPP 2.0.1 additionally exposes EvseId.
Method: C_OCPP:Get(name)
Arguments:
Name: name
Type: String
Description: State field name (see the list above).
Return: Value of the field — a number for numeric fields, a string for textual ones — or an
error for an unknown name.
Method: C_OCPP:Set(name, value)
Arguments:
Name: name
Type: String
Description: State field name. Numeric fields accept an integer or float; textual
fields accept a string.
Name: value
Type: integer | float | string
Return: nil (errors on an unknown name or a type mismatch).
In addition, every supported OCPP action is callable as C_OCPP:<Action>(overrides?). The set of
actions is version-specific (OCPP 1.6, 2.0.1 and 2.1 differ), so a script must match the device's OCPP
version. The action's payload is built from the current state exactly like the on-screen action
buttons; an optional table of overrides is shallow-merged over it. The call returns true once the
action is queued, or false on an argument error. The result of the exchange with the CSMS appears
in the module's Messages table, not in the return value.
Method: C_OCPP:<Action>(overrides?)
e.g. C_OCPP:Authorize(), C_OCPP:StartTransaction(), C_OCPP:MeterValues()
C_OCPP:BootNotification({ chargePointModel = "Custom" })
Arguments:
Name: overrides
Type: table (optional)
Description: Key/value fields shallow-merged over the state-derived payload.
Return: true when the action was queued, false on an argument error.
The same Get/Set/<Action> surface is reachable on a narrower scope. On the client a
connector accessor is obtained by id; on the server the connected stations and their
connectors are enumerated and then addressed by id.
Method: C_OCPP:Connector(id) -- client role
Return: accessor scoped to connector `id`, exposing Get/Set/<Action>.
Method: C_OCPP:GetChargingStations() -- server role
Return: list of connected charging-station ids.
Method: C_OCPP:GetConnectors(cs) -- server role
Return: list of connector ids seen for station `cs`.
Method: C_OCPP:ChargingStation(cs) -- server role
Return: accessor scoped to station `cs` (or nil if unknown), exposing Get/Set/<Action>.
Method: C_OCPP:Connector(cs, id) -- server role
Return: accessor scoped to connector `id` of station `cs` (or nil if unknown),
exposing Get/Set/<Action>.
-- Ramp the charging current while a transaction is running, then report it.
local target = 16.0
local current = C_OCPP:Get("CurrentL1")
if current < target then
C_OCPP:Set("CurrentL1", current + 0.5)
C_OCPP:Set("CurrentL2", current + 0.5)
C_OCPP:Set("CurrentL3", current + 0.5)
C_OCPP:MeterValues()
end













