Quick Start¶
This quick-start guide will introduce you step-by-step to setting up a tbot environment for automating your development workflow. Please start by installing tbot as detailed in Installation.
This guide assumes you have an embedded Linux system like a BeagleBone or Raspberry Pi (or some other board) with a serial console you can access.
Note
This version of the quick-start guide uses newbot
, tbot’s new
commandline tool. It is easier to use than the old CLI tool (tbot
). At
some point in the future, the old tool will be removed. For migration,
please first read this guide to familiarize yourself with the new tool.
Then, you can find additional information in tbot CLI about switching to
newbot
.
1. Directory Structure¶
To start, let’s set up a basic directory structure. Importantly, you can do this completely different - here is just a simple and proven suggestion.
$ mkdir tbot-example && cd tbot-example && git init
$ mkdir -p config/ tc/
$ touch config/my_config.py tc/interactive.py tc/examples.py
$ tree
./
├──config/
│ └──my_config.py
└──tc/
├──examples.py
└──interactive.py
There are two directories here:
config/
: This directory is a Python module containing all configurations. For now, there is only one.tc/
: This directory is a Python module containing “testcases”. “Testcase” just means a callable routine which does something. “Task” might also be an adequate description.
Next, we will fill tc/interactive.py
with some “testcase” code. This will
help with the next step. Please add the following code to
tc/interactive.py
. I will explain what it does later in this guide.
import tbot
@tbot.testcase
def board() -> None:
"""Open an interactive session on the board's serial console."""
with tbot.ctx.request(tbot.role.Board) as b:
b.interactive()
@tbot.testcase
def linux() -> None:
"""Open an interactive session on the board's Linux shell."""
with tbot.ctx.request(tbot.role.BoardLinux) as lnx:
lnx.interactive()
If your board uses U-Boot and you also intend to interact with it, you can also add this one:
@tbot.testcase
def uboot() -> None:
"""Open an interactive session on the board's U-Boot shell."""
tbot.ctx.teardown_if_alive(tbot.role.BoardLinux)
with tbot.ctx.request(tbot.role.BoardUBoot) as ub:
ub.interactive()
2. Simple Testcases¶
Now it is time to write some testcases ourselves. Again, testcase just means
“callable function”/”task” in tbot. Start with the following skeleton in
tc/examples.py
:
import tbot
@tbot.testcase
def hello_world():
tbot.log.message("Hello World!")
You can now run this testcase like this:
$ newbot tc.examples.hello_world
tbot starting ...
├─Calling hello_world ...
│ ├─Hello World!
│ └─Done. (0.000s)
├─────────────────────────────────────────
└─SUCCESS (0.218s)
As you can see, you need to pass a sort of module path to newbot
to run a
testcase. Under the hood, you can imagine tbot is doing nothing more than:
# newbot tc.examples.hello_world
# ... essentially does:
import tc.examples
tc.examples.hello_world()
The next testcase will run some commands on the localhost (= the machine tbot is running on). To do this, we first need to introduce the concept of machines:
3. Machines¶
Any “host” or board tbot can interact with is called a machine. We can interact with them by instantiating a machine and then calling methods on this instance. To make everything a bit more generic, instantiation usually happens by requesting a role. A role is later filled in with a concrete machine in the configuration. A few roles have default machines attached, so for now, no config is needed. In practice:
import tbot
@tbot.testcase
def hello_machine():
with tbot.ctx.request(tbot.role.LocalHost) as lo:
lo.exec0("uname", "-a")
host = lo.exec0("hostname")
tbot.log.message(f"This host is called: {host}")
Add this hello_machine()
testcase to tc/examples.py
and run it:
$ newbot tc.examples.hello_machine
tbot starting ...
├─Calling hello_machine ...
│ ├─[local] uname -a
│ │ ## Linux sandvich 5.17.4-arch1-1 #1 SMP PREEMPT Wed, 20 Apr 2022 18:29:28 +0000 x86_64 GNU/Linux
│ ├─[local] hostname
│ │ ## sandvich
│ └─Done. (0.070s)
├─────────────────────────────────────────
└─SUCCESS (0.210s)
In this case, the role tbot.role.LocalHost
is used. It describes
the host tbot is running on. After requesting an instance of it, we can use
methods on the instance to run commands.
exec0()
runs a command and
asserts that its return code is 0. It returns the command’s output (both stdout
and stderr interleaved). The command is passed as multiple arguments, each
containing one “shell token”. tbot automatically escapes everything correctly.
To make this more clear, here are a few shell commands and their equivalent
tbot call:
# uname -a
lo.exec0("uname", "-a")
# find / -name "*.git"
lo.exec0("find", "/", "-name", "*.git")
# echo '${this is not expanded}'
lo.exec0("echo", "${this is not expanded}")
# git commit -a -m "commit message"
lo.exec0("git", "commit", "-a", "-m", "commit message")
Linux machines have a lot of other methods to make interaction easy. Here is a more involved example:
@tbot.testcase
def machine_interaction():
with tbot.ctx.request(tbot.role.LocalHost) as lo:
# get the value of the ${HOME} environment variable
home = lo.env("HOME")
# test if it is a directory. test() returns True if the command
# succeeded and False otherwise
if lo.test("test", "-d", home):
tbot.log.message("${HOME} is a real directory!")
# exec() returns a tuple of (retcode, output)
retcode, output = lo.exec("systemctl", "status", "multi-user.target")
if retcode != 0:
tbot.log.warning("systemctl failed?")
For a full list, check the documentation for Linux Shells.
There is one more method I want to highlight here: interactive()
. It allows you to drop into an
interactive shell session at any time. In its simplest form, we used it in
tc/interactive.py
at the very beginning. But you can use it at any point
in your own code as well. This is very useful while developing testcases.
4. Configuration¶
The configuration module tells tbot what machines exist beyond the localhost and how to interact with each one. Such machines might be a remote server which can be reached over SSH, your board, or the Linux on your board.
The last two are important to distinguish: The “bare” board is treated as a separate machine from the Linux running on it. This split tries to separate the physical hardware from the “virtual” software running on it. The software might be the same for multiple boards, so this scheme allows reusing parts of the configuration.
The configuration module in our case will be config.my_config
and as such
it is stored in config/my_config.py
. Let’s start by creating a simple
configuration for a board with a serial console. Please copy the following
code into config/my_config.py
and adjust it appropriately:
import tbot
from tbot.machine import board, connector, linux
class MyBoard(connector.ConsoleConnector, board.Board):
baudrate = 115200
serial_port = "/dev/ttyUSB0"
def connect(self, mach):
return mach.open_channel("picocom", "-b", str(self.baudrate), self.serial_port)
def register_machines(ctx):
ctx.register(MyBoard, tbot.role.Board)
MyBoard
is a class describing the “board machine”. In this case, it just
connects to the serial console using picocom. We will look at the details
of this class in the next section.
The register_machines()
function is special: It will be called by tbot to
“activate” this configuration. Its job is to register all the new machines for
appropriate roles. Here, we register MyBoard
for the
tbot.role.Board
class.
We can already try out this configuration using one of the testcases from
earlier. Call the tc.interactive.board
testcase and powercycle the board.
You will enter an session on the serial console where you can interact with the
board. Press CTRL-D
to exit.
$ newbot -c config.my_config tc.interactive.board
tbot starting ...
├─Calling board ...
│ ├─[local] picocom -q -b 115200 /dev/ttyUSB1
│ ├─Entering interactive shell (CTRL+D to exit) ...
U-Boot 2022.01 (Apr 06 2022 - 14:09:55 +0000)
CPU: Freescale i.MX6UL rev1.1 528 MHz (running at 396 MHz)
Reset cause: POR
Scanning mmc 0:1...
Found U-Boot script /boot/boot.scr
1691 bytes read in 2 ms (825.2 KiB/s)
## Executing script at 86000000
5490104 bytes read in 124 ms (42.2 MiB/s)
Kernel image @ 0x82000000 [ 0x000000 - 0x53c5b8 ]
## Flattened Device Tree blob at 84000000
Booting using the fdt blob at 0x84000000
Loading Device Tree to 8ef71000, end 8ef7b0d2 ... OK
Starting kernel ...
[ 2.658999] sd 0:0:0:0: [sda] No Caching mode page found
[ 2.664394] sd 0:0:0:0: [sda] Assuming drive cache: write through
Xyz Linux 2022.04 test ttymxc5
test login: root
root@emb-imx6ul:~# uname -a
Linux test 5.10.99 #1 Tue Feb 8 17:30:41 UTC 2022 armv7l GNU/Linux
root@emb-imx6ul:~#
│ └─Done. (72.616s)
├─────────────────────────────────────────
└─SUCCESS (72.676s)
The -c
argument is used to tell newbot
which configuration to load.
Again, there is little magic here. You can imagine tbot is just doing this:
# newbot -c config.my_config
# ... essentially does:
import config.my_config
config.my_config.register_machines(tbot.ctx)
After that, the listed testcases are called like before.
A thing worth mentioning is that you can pass -c
multiple times. This
means you can modularize your configuration and mix-and-match from the
commandline.
5. Machines in depth¶
Machines in tbot have two core parts:
A “connector” which defines how to open the connection to this machine. This can be opening a serial console (
ConsoleConnector
) or ssh-ing to a server (SSHConnector
), for example.A “shell” which defines how to interact with the machine once the connection is established. On Linux, this could be
linux.Bash
orlinux.Ash
. Or for U-Boot, there isboard.UBoot
. For the bare board above, we usedboard.Board
as the shell.
A machine class must inherit from a connector class and a shell class. These
classes usually demand that the machine class then defines additional
attributes and methods to configure them. For example, the
ConsoleConnector
requires you to define a
connect()
method. Similarly, the
SSHConnector
requires a hostname
attribute. You can check their documentation for more info.
In addition to connector and shell, there can be optional initializers. These
come in many flavors: PreConnectInitializer
,
Initializer
(runs between connect and shell), and
PostShellInitializer
.
6. Power Control¶
One such initializer would be board.PowerControl
. It allows controlling board power
automatically. If you have a switchable socket or relay connected to your
board’s power, you can integrate it like this:
class MyBoard(connector.ConsoleConnector, board.PowerControl, board.Board):
baudrate = 115200
serial_port = "/dev/ttyUSB0"
def poweron(self):
with tbot.ctx.request(tbot.role.LocalHost) as lo:
lo.exec0("sispmctl", "-o", "3")
def poweroff(self):
with tbot.ctx.request(tbot.role.LocalHost) as lo:
lo.exec0("sispmctl", "-f", "3")
def connect(self, mach):
return mach.open_channel("picocom", "-b", str(self.baudrate), self.serial_port)
Now you don’t need to manually powercycle the board anymore!
A quick tip: If I can’t control power for some hardware, I still use
board.PowerControl
. Instead of
real commands, I instruct it to notify me of the need for a manual powercycle:
def poweron(self):
with tbot.ctx.request(tbot.role.LocalHost) as lo:
lo.exec0("notify-send", "Powercycle", "Powercycle foo board please!")
tbot.log.message("Powercycle now!")
def poweroff(self):
pass
7. Board Linux¶
So far, tbot cannot really “interact” with the board. We only got the interactive console. Let’s change this by telling tbot about the Linux system running on the board. To do this, we need to define a new machine. This machine
“connects” to the existing board machine’s console using the special
board.Connector
,then uses the
board.LinuxBootLogin
initializer to wait for the login prompt and then log in,and finally it will use a
linux.Ash
shell to interact with the Linux shell.
The configuration module in config/my_config.py
now looks like this:
import tbot
from tbot.machine import board, connector, linux
class MyBoard(connector.ConsoleConnector, board.PowerControl, board.Board):
baudrate = 115200
serial_port = "/dev/ttyUSB0"
def poweron(self):
with tbot.ctx.request(tbot.role.LocalHost) as lo:
lo.exec0("sispmctl", "-o", "3")
def poweroff(self):
with tbot.ctx.request(tbot.role.LocalHost) as lo:
lo.exec0("sispmctl", "-f", "3")
def connect(self, mach):
return mach.open_channel("picocom", "-b", str(self.baudrate), self.serial_port)
class MyBoardLinux(board.Connector, board.LinuxBootLogin, linux.Ash):
username = "root"
password = "hunter2" # or `None` if no password is needed
def register_machines(ctx):
ctx.register(MyBoard, tbot.role.Board)
ctx.register(MyBoardLinux, tbot.role.BoardLinux)
Similar to before, you can test this config with tc.interactive.linux
to
get an interactive session on the board Linux. But to really show where tbot
provides added value, let’s write a testcase to automate something. Add this
to tc/examples.py
:
import tbot
@tbot.testcase
def board_linux():
with tbot.ctx.request(tbot.role.BoardLinux) as lnx:
lnx.exec0("ip", "address")
lnx.exec0("uname", "-a")
lnx.exec0("cat", "/etc/os-release")
Run it!
$ newbot -c config.my_config tc.examples.board_linux
tbot starting ...
├─Calling board_linux ...
│ ├─[local] picocom -q -b 115200 /dev/ttyUSB0
│ ├─POWERON (my-board)
│ ├─[local] sispmctl -o 3
│ │ ## Accessing Gembird #0 USB device 002
│ │ ## Switched outlet 3 on
│ ├─LINUX (my-board-linux)
│ │ <>
│ │ <> U-Boot 2022.01 (Apr 06 2022 - 14:09:55 +0000)
│ │ <>
│ │ <> CPU: Freescale i.MX6UL rev1.1 528 MHz (running at 396 MHz)
│ │ <> Reset cause: POR
│ │ <> Scanning mmc 0:1...
│ │ <> Found U-Boot script /boot/boot.scr
│ │ <> 1691 bytes read in 2 ms (825.2 KiB/s)
│ │ <> ## Executing script at 86000000
│ │ <> 5490104 bytes read in 124 ms (42.2 MiB/s)
│ │ <> Kernel image @ 0x82000000 [ 0x000000 - 0x53c5b8 ]
│ │ <> ## Flattened Device Tree blob at 84000000
│ │ <> Booting using the fdt blob at 0x84000000
│ │ <> Loading Device Tree to 8ef71000, end 8ef7b0d2 ... OK
│ │ <>
│ │ <> Starting kernel ...
│ │ <>
│ │ <> [ 2.658999] sd 0:0:0:0: [sda] No Caching mode page found
│ │ <> [ 2.664394] sd 0:0:0:0: [sda] Assuming drive cache: write through
│ │ <>
│ │ <> Xyz Linux 2022.04 test ttymxc5
│ │ <>
│ │ <> test login:
│ ├─[my-board-linux] ip address
│ │ ## 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
│ │ ## inet 127.0.0.1/8 scope host lo
│ │ ## valid_lft forever preferred_lft forever
│ │ ## 2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast qlen 1000
│ │ ## inet 10.0.0.100/16 brd 10.10.255.255 scope global dynamic eth0
│ │ ## valid_lft 85290sec preferred_lft 85290sec
│ ├─[my-board-linux] uname -a
│ │ ## Linux test 5.10.99 #1 Tue Feb 8 17:30:41 UTC 2022 armv7l GNU/Linux
│ ├─[my-board-linux] cat /etc/os-release
│ │ ## ID=test
│ │ ## NAME="Xyz Linux"
│ │ ## VERSION="2022.04"
│ │ ## VERSION_ID=2022.04
│ │ ## PRETTY_NAME="Xyz Linux 2022.04"
│ ├─POWEROFF (my-board)
│ ├─[local] sispmctl -f 4
│ │ ## Accessing Gembird #0 USB device 002
│ │ ## Switched outlet 4 off
│ └─Done. (32.864s)
├─────────────────────────────────────────
└─SUCCESS (32.925s)
That’s it for a basic overview of tbot! Here are links to resources you could dive into next:
tbot CLI: The
newbot
commandline interfaceConfiguration: More details on configuration
Context: The mechanism for requesting machine instances