Plugin's Development Guide v1
July 24, 2026 · View on GitHub
Welcome to plugin's development guide v1. This document is going to guide you to develop plugins for Scope serial monitor tool. But, What is a plugin? A plugin is a script written in lua that adds new behaviors of the Scope serial monitor tool. You can imagine plugins as an external chip that you connect to a handheld tool to add a new feature to it.
Ok, you already know what is a plugin, and now you may be wondering: What I can do with plugins?. With plugins, you can:
- Simulate devices with automatic responses;
- Build test suites to send messages and check responses, automatically;
- Decode codified messages that coming from serial;
- Execute terminal programs inside
Scope; - And what your creativity and skill allow.
Prerequisites
Plugins rely on two standard libraries — scope.lua and shell.lua — which provide the require("scope") and require("shell") modules used throughout this guide.
You don't need to copy them next to your plugin. When you load a plugin, Scope copies it into <config_dir>/scope/plugins/ (e.g. ~/.config/scope/plugins/) and writes the standard libraries there alongside it, so require(...) resolves automatically. Because your plugin is loaded from that directory, reload re-copies it from the original path — keep editing the file you loaded and reload as usual. (Both libraries also live in the plugins folder of the Scope repository if you want to read them.)
Getting Started
The best way to understand how plugins works is seeing a simple sample running. You can copy the snippet below and save it at hello.lua or download it from here.
local serial = require("scope").serial
local M = {}
function M.on_serial_recv(msg)
serial.send("Hello," .. msg)
end
return M
To execute this plugin you need to load it into the Scope. With the Scope open, you could type !plugin load hello.lua. If you remember the analogy of the chip and the handheld tool, then you need to insert the chip into handheld to it works. Likewise, we need to "insert" (or load) our plugin into our "handheld" (or the Scope program). With the plugin loaded, all messages will be replied. The replied message will have the following suffix: Hello,.
!plugin load lasts only for the current session. If a plugin is part of your everyday workflow, install it instead with !plugin install hello.lua: this loads it now and records it so every subsequent Scope session auto-loads it at start-up (the name is stored in installed.toml, next to the staged plugins in <config_dir>/scope/plugins/). Use !plugin list to see what's installed, and !plugin uninstall hello to remove one from the auto-load set (it also deletes the staged copy; a plugin already running this session stays loaded until you !plugin unload it or restart).
Hello, World
Let's break down each line of the sample above. At the first line we're importing the serial functions from the scope standard library. We need this import to interact with the current connected serial port.
We create a local table at the third line. This local table is our plugin, and we name it M by a convention. We could name it as our plugin name hello for example. What's matter is the table must be returned at the end of file.
Through line 5 to 7 we write a function and there are two notes about this function: the function is associated to our plugin table, and it has a reserved name on_serial_recv. The former tell us that any function outside our plugin table will be ignored and the later tell us that this function will be called on every message received from serial interface. Inside the function body, we're sending a message through serial, using serial.send function. As function argument, we're concatenating "Hello," to the received message. There are two other functions for serial interaction inside scope standard library: serial.info which returns the configured serial port and its baud rate; and serial.recv which waits and returns a tuple with the error and the received message. For serial.recv, you can pass a table as argument to specify what is the timeout to wait. If the timeout is reach, then the error returned isn't nil.
The last line is already explained: it returns our plugin table. Without this line, anything inside our plugin will have effect.
RTT
Scope can also operate using RTT (Real-Time Transfer). When the active interface is RTT, plugins can interact with it through the rtt module from the Scope standard library.
Import it like this:
local rtt = require("scope").rtt
As with the serial APIs, RTT messages are byte-oriented. Depending on the call site, data may arrive as a Lua string or as a list of bytes (a table of numbers). If you need to convert between them, first import the Scope standard library with require("scope"), then use the fmt helpers:
local scope = require("scope")
local fmt = scope.fmt
local as_string = fmt.to_str(msg)
local as_bytes = fmt.to_bytes("hello")
rtt.info()
Returns information about the current RTT session.
- Returns:
target, channeltarget(string): RTT target name.channel(number): active RTT channel.
Notes:
- If the active interface is not RTT, Scope returns an empty target (
"") and channel0.
rtt.send(msg)
Sends a message through the RTT interface.
msg: string or list of bytes (table of numbers).- Returns: nothing.
rtt.recv(opts)
Waits for the next RTT message.
opts(table):timeout_ms(number, optional): how long to wait in milliseconds. If omitted, it waits indefinitely.
- Returns:
err, dataerr:nilon success, or a string on error (currently the most common value is"timeout").data: list of bytes (table of numbers). On timeout, it is an empty list.
Important:
- If your plugin implements
on_rtt_recv, it will still be called for the same incoming message that unblocksrtt.recv. Avoid processing the same message twice. rtt.recvrequires the active interface to be RTT. If another interface is active, this call may wait indefinitely and never complete.
rtt.read(opts)
Reads raw memory from the target via the RTT backend.
opts(table):address(number): memory address to read from.size(number): number of bytes to read (maximum:1024).
- Returns:
err, dataerr:nilon success, or a string describing the failure.data: list of bytes (table of numbers). On error, it is an empty list.
Notes:
- This call requires the active interface to be RTT. If Scope is running with another interface selected, the request will immediately fail with an error indicating that RTT is not the active interface. Use
rtt.info()to detect whether RTT is active. - Lua numbers are typically floating-point; very large addresses may lose precision. In practice, this works best for 32-bit addresses.
Callback: on_rtt_recv(msg)
If your plugin table defines on_rtt_recv, Scope will call it automatically every time a message is received from RTT while the active interface is RTT.
local scope = require("scope")
local fmt = scope.fmt
local log = scope.log
local M = {}
function M.on_rtt_recv(msg)
log.info("RTT: " .. fmt.to_str(msg))
end
return M
Notes:
- When the active interface is RTT,
on_serial_recvis not called; RTT useson_rtt_recvinstead.
Callback: on_rtt_send(msg)
If your plugin table defines on_rtt_send, Scope will call it automatically every time a message is sent to RTT while the active interface is RTT.
local scope = require("scope")
local fmt = scope.fmt
local log = scope.log
local M = {}
function M.on_rtt_send(msg)
log.info("RTT sent: " .. fmt.to_str(msg))
end
return M
Notes:
- When the active interface is RTT,
on_serial_sendis not called; RTT useson_rtt_sendinstead.
Analytics Plugin
After understand the basic plugin sample shown above, let's move on to a more complex and functional sample. Let's build an analytics plugin. You can use the code of hello.lua as base and edit the same file or duplicate the file and rename it to analytics.lua. This plugin is going to count the number of times we receive and send a message through the serial port. We already use on_serial_recv on the previous sample to get the received messages. To get the messages sent we're going to use on_serial_send function. See the snippet below:
local serial = require("scope").serial
local M = {}
function M.on_serial_recv(msg)
serial.send("Hello," .. msg)
end
function M.on_serial_send(msg)
end
return M
Now, to count the messages sent and received let's create two variables to store these values. We could create this globally, however is better to create that inside our plugin table M. When the plugin is loaded by Scope, all global variable is lost, including the plugin table M. This is the reason because we return the plugin table M. So, the following snippet is going to show the two new variables: recv and send.
local serial = require("scope").serial
local M = {
recv = 0,
send = 0,
}
function M.on_serial_recv(msg)
serial.send("Hello," .. msg)
end
function M.on_serial_send(msg)
end
return M
As we're planning, let's increase these values to register our analytics. So, inside on_serial_recv and on_serial_send function body we'll increase recv and send respectively. The bellow snippet shows where to increase these variables:
local serial = require("scope").serial
local M = {
recv = 0,
send = 0,
}
function M.on_serial_recv(msg)
M.recv = M.recv + 1
serial.send("Hello," .. msg)
end
function M.on_serial_send(msg)
M.send = M.recv + 1
end
return M
Ok, we've already saved the amount of messages sent and received, however we can't access these values. To access these values we could save them into a file and saw it later. To save it into a file we're going to use the standard Lua file API. The following snippet is showing the new version of our plugin with file persistence.
local serial = require("scope").serial
local M = {
recv = 0,
send = 0,
}
function M.on_serial_recv(msg)
M.recv = M.recv + 1
serial.send("Hello," .. msg)
local file = io.open('analytics.txt', 'w')
file:write(tostring(M.send) .. '\n' .. tostring(M.recv))
file:close()
end
function M.on_serial_send(msg)
M.send = M.recv + 1
local file = io.open('analytics.txt', 'w')
file:write(tostring(M.send) .. '\n' .. tostring(M.recv))
file:close()
end
return M
If you notice, we're duplicating the file persistence code. Let's move it from functions body to its local own function.
local serial = require("scope").serial
local M = {
recv = 0,
send = 0,
}
local function save()
local file = io.open('analytics.txt', 'w')
file:write(tostring(M.send) .. '\n' .. tostring(M.recv))
file:close()
end
function M.on_serial_recv(msg)
M.recv = M.recv + 1
serial.send("Hello," .. msg)
save()
end
function M.on_serial_send(msg)
M.send = M.recv + 1
save()
end
return M
Our plugin is almost done. We've already counted and saved the collected values. But, there is a little issue here: each time we receive and send a message we're opening and closing a file. This slow down our plugin. To fix that we need to open and close the file once. For this feature we'll use two new functions: on_load and on_unload. The on_load is called when the plugin is loaded and on_unloaded is called when the plugin is unloaded or the scope is closed. In the next snippet we're going to open the file at on_load and close it at on_unload.
local serial = require("scope").serial
local M = {
recv = 0,
send = 0,
f = nil
}
local function save()
M.f:write(tostring(M.send) .. '\n' .. tostring(M.recv))
end
function M.on_load()
M.f = io.open('analytics.txt', 'w')
end
function M.on_unload()
M.f:close()
end
function M.on_serial_recv(msg)
M.recv = M.recv + 1
serial.send("Hello," .. msg)
save()
end
function M.on_serial_send(msg)
M.send = M.recv + 1
save()
end
return M
With that, we finish our analytics plugin. If you notice, we use a lot of functions that starts with on_. Functions inside our plugin's table that have on_ prefix are called as Event Callbacks. The Scope calls these functions automatically when its conditions are matched. You shouldn't start your custom functions by on_ to prevent confusion with event callbacks.
Logs
There is a detail we don't check: the file open result. We need to check whether the result isn't nil and show an error to the user if it is. To show the error, we can import the log functions from scope standard library. These functions show messages inside the Scope main view (where the serial messages are displayed). As we need to show an error, we'll use the log.error function to print a message in red.
local serial = require("scope").serial
local log = require("scope").log
local M = {
recv = 0,
send = 0,
f = nil
}
local function save()
M.f:write(tostring(M.send) .. '\n' .. tostring(M.recv))
end
function M.on_load()
M.f = io.open('analytics.txt', 'w')
if M.f == nil then
log.error("File analytics.txt can't be opened")
end
end
function M.on_unload()
M.f:close()
end
function M.on_serial_recv(msg)
M.recv = M.recv + 1
serial.send("Hello," .. msg)
save()
end
function M.on_serial_send(msg)
M.send = M.recv + 1
save()
end
return M
There are other functions to show messages to the user:
log.debug: To print debug messages in cyan;log.info: To print info messages in white;log.success: To print success messages in green;log.warning: To print warning messages in yellow;log.error: To print error messages in red.
Commands
We can enhance our plugin adding a way to print the amount of messages received and sent. To implement this feature, we can add a command to our plugin. To add a command to a plugin, you only need to add a function to the plugin's table. The function name will be the name of the command. Let's create a command called data to show the analytics values.
local serial = require("scope").serial
local log = require("scope").log
local M = {
recv = 0,
send = 0,
f = nil
}
local function save()
M.f:write(tostring(M.send) .. '\n' .. tostring(M.recv))
end
function M.on_load()
M.f = io.open('analytics.txt', 'w')
if M.f == nil then
log.error("File analytics.txt can't be opened")
end
end
function M.on_unload()
M.f:close()
end
function M.on_serial_recv(msg)
M.recv = M.recv + 1
serial.send("Hello," .. msg)
save()
end
function M.on_serial_send(msg)
M.send = M.recv + 1
save()
end
function M.data()
log.info("Tx: " .. tostring(M.send) .. ", Rx: " .. tostring(M.recv))
end
return M
To call this function we need to use ! followed by: plugin's name, the command name and its arguments (split by spaces). For example, if your plugin name is analytics.lua you can call the data command using this command:
!analytics data
If you call this command, and it didn't work it's because you don't reload the plugin. Each change made inside the plugin's source code doesn't make effect until you reload the plugin. To reload the plugin (assuming its name as analytics.lua) you can run the command below:
!plugin load analytics.lua
Regex
A good statistic for our analytics plugin is how many times a message that starts with AT appears. This is a good way to check how many AT commands has sent. First, let's import the regex functions from scope standard library.
local serial = require("scope").serial
local log = require("scope").log
local re = require("scope").re
local M = {
recv = 0,
send = 0,
f = nil
}
local function save()
M.f:write(tostring(M.send) .. '\n' .. tostring(M.recv))
end
function M.on_load()
M.f = io.open('analytics.txt', 'w')
if M.f == nil then
log.error("File analytics.txt can't be opened")
end
end
function M.on_unload()
M.f:close()
end
function M.on_serial_recv(msg)
M.recv = M.recv + 1
serial.send("Hello," .. msg)
save()
end
function M.on_serial_send(msg)
M.send = M.recv + 1
save()
end
function M.data()
log.info("Tx: " .. tostring(M.send) .. ", Rx: " .. tostring(M.recv))
end
return M
After that, let's add the variable at to our plugin table. This variable will count how many times we detect an AT command.
local serial = require("scope").serial
local log = require("scope").log
local re = require("scope").re
local M = {
recv = 0,
send = 0,
at = 0,
f = nil
}
local function save()
M.f:write(tostring(M.send) .. '\n' .. tostring(M.recv))
end
function M.on_load()
M.f = io.open('analytics.txt', 'w')
if M.f == nil then
log.error("File analytics.txt can't be opened")
end
end
function M.on_unload()
M.f:close()
end
function M.on_serial_recv(msg)
M.recv = M.recv + 1
serial.send("Hello," .. msg)
save()
end
function M.on_serial_send(msg)
M.send = M.recv + 1
save()
end
function M.data()
log.info("Tx: " .. tostring(M.send) .. ", Rx: " .. tostring(M.recv))
end
return M
At M.on_serial_send we'll use the re.match function to check if the message starts with AT. The below snippet shows this inclusion.
local serial = require("scope").serial
local log = require("scope").log
local re = require("scope").re
local M = {
recv = 0,
send = 0,
at = 0,
f = nil
}
local function save()
M.f:write(tostring(M.send) .. '\n' .. tostring(M.recv))
end
function M.on_load()
M.f = io.open('analytics.txt', 'w')
if M.f == nil then
log.error("File analytics.txt can't be opened")
end
end
function M.on_unload()
M.f:close()
end
function M.on_serial_recv(msg)
M.recv = M.recv + 1
serial.send("Hello," .. msg)
save()
end
function M.on_serial_send(msg)
M.send = M.recv + 1
if re.match(msg, "AT.*") then
M.at = M.at + 1
end
save()
end
function M.data()
log.info("Tx: " .. tostring(M.send) .. ", Rx: " .. tostring(M.recv))
end
return M
We also could count the AT commands that contains AT+. However, the + character is a reserver symbol in regex. We could escape it using reverse slash \ or using the re.literal function. This function will escape every special character for us.
local serial = require("scope").serial
local log = require("scope").log
local re = require("scope").re
local M = {
recv = 0,
send = 0,
at = 0,
at_plus = 0,
f = nil
}
local function save()
M.f:write(tostring(M.send) .. '\n' .. tostring(M.recv))
end
function M.on_load()
M.f = io.open('analytics.txt', 'w')
if M.f == nil then
log.error("File analytics.txt can't be opened")
end
end
function M.on_unload()
M.f:close()
end
function M.on_serial_recv(msg)
M.recv = M.recv + 1
serial.send("Hello," .. msg)
save()
end
function M.on_serial_send(msg)
M.send = M.recv + 1
if re.match(msg, "AT.*") then
M.at = M.at + 1
end
if re.match(msg, re.literal("AT+")) then
M.at_plus = M.at_plus + 1
end
save()
end
function M.data()
log.info("Tx: " .. tostring(M.send) .. ", Rx: " .. tostring(M.recv))
end
return M
If we add more patterns to match it'll become extensive and hard to maintain. There is a special function to help us. The function re.matches gets an input and matches against a list of pairs. Each pair must have a pattern followed by a function. If the pattern matches it'll call the associated function. Note that the re.matches will use the first matched pattern and will stop on that. The following snippet shows this function usage.
local serial = require("scope").serial
local log = require("scope").log
local re = require("scope").re
local M = {
recv = 0,
send = 0,
at = 0,
at_plus = 0,
number = 0,
f = nil
}
local function save()
M.f:write(tostring(M.send) .. '\n' .. tostring(M.recv))
end
function M.on_load()
M.f = io.open('analytics.txt', 'w')
if M.f == nil then
log.error("File analytics.txt can't be opened")
end
end
function M.on_unload()
M.f:close()
end
function M.on_serial_recv(msg)
M.recv = M.recv + 1
serial.send("Hello," .. msg)
save()
end
function M.on_serial_send(msg)
M.send = M.recv + 1
re.matches(msg,
"AT.*", function(msg)
M.at = M.at + 1
end,
re.literal("AT+"), function(msg)
M.at_plus = M.at_plus + 1
end,
"\\d+", function(msg)
M.number = M.number + 1
end
)
save()
end
function M.data()
log.info("Tx: " .. tostring(M.send) .. ", Rx: " .. tostring(M.recv))
end
return M
Shell
If you need to call a shell command inside your lua plugin, you need to create a shell session. This session is isolated from any other shell session, and it lives while the plugin is loaded. First we need to import the lua file shell.lua. After that, we're going to create a new shell session with the shell.new function. This function will create an object to us. This object is our shell session.
local serial = require("scope").serial
local log = require("scope").log
local re = require("scope").re
local shell = require("shell")
local M = {
recv = 0,
send = 0,
at = 0,
at_plus = 0,
number = 0,
shell = nil,
f = nil
}
local function save()
M.f:write(tostring(M.send) .. '\n' .. tostring(M.recv))
end
function M.on_load()
M.shell = shell.new()
M.f = io.open('analytics.txt', 'w')
if M.f == nil then
log.error("File analytics.txt can't be opened")
end
end
function M.on_unload()
M.f:close()
end
function M.on_serial_recv(msg)
M.recv = M.recv + 1
serial.send("Hello," .. msg)
save()
end
function M.on_serial_send(msg)
M.send = M.recv + 1
re.matches(msg,
"AT.*", function(msg)
M.at = M.at + 1
end,
re.literal("AT+"), function(msg)
M.at_plus = M.at_plus + 1
end,
"\\d+", function(msg)
M.number = M.number + 1
end
)
save()
end
function M.data()
log.info("Tx: " .. tostring(M.send) .. ", Rx: " .. tostring(M.recv))
end
return M
After this point, we'll always use M.shell object's method. To use methods in lua we use : instead of .. If you swap these symbols the code won't work. The shell object M.shell have two methods: exist which checks if a program exists, and run to run a command. We'll use run to run an echo. The output of the program is printed at the Scope main view.
local serial = require("scope").serial
local log = require("scope").log
local re = require("scope").re
local shell = require("shell")
local M = {
recv = 0,
send = 0,
at = 0,
at_plus = 0,
number = 0,
shell = nil,
f = nil
}
local function save()
M.f:write(tostring(M.send) .. '\n' .. tostring(M.recv))
end
function M.on_load()
M.shell = shell.new()
M.shell:run("echo Hello")
M.f = io.open('analytics.txt', 'w')
if M.f == nil then
log.error("File analytics.txt can't be opened")
end
end
function M.on_unload()
M.f:close()
end
function M.on_serial_recv(msg)
M.recv = M.recv + 1
serial.send("Hello," .. msg)
save()
end
function M.on_serial_send(msg)
M.send = M.recv + 1
re.matches(msg,
"AT.*", function(msg)
M.at = M.at + 1
end,
re.literal("AT+"), function(msg)
M.at_plus = M.at_plus + 1
end,
"\\d+", function(msg)
M.number = M.number + 1
end
)
save()
end
function M.data()
log.info("Tx: " .. tostring(M.send) .. ", Rx: " .. tostring(M.recv))
end
return M
System info
There is a set of functions to help the developer. This function are inside the sys of the scope standard library.
The function os_name returns the name of running operating system. If the OS is a Windows version, it'll return windows otherwise it'll return unix.
The function sleep_ms sleeps the current function for x milliseconds. Be careful with this function, because it could slow down the plugin execution.
And last but not least, we have the function parse_args. This is a helper function to check the input arguments of a custom command. It receives a list of tables. Each table check one argument. There are 2 mandatory fields for each table: arg which is the argument name and ty, its type. There are 3 possible values for ty: string, number and boolean. In addition to these mandatory fields, there are 2 other optional fields: default which replace a missing argument, and validate that runs to check if the input argument is valid. If it's not valid, so the lua assert is called and the command isn't run.