Using Otii for automated testing in a Docker environment
These features require an Automation Toolbox license.Docker containers are valuable in CI systems because they provide consistent, reproducible environments across every stage of the pipeline. By packaging an application along with its dependencies, runtime, and configuration into a single image, containers eliminate the classic "works on my machine" problem—builds and tests behave identically whether they run on a developer's laptop, a CI runner, or a production-like staging server. They also start quickly and are isolated from one another, which lets CI systems run parallel jobs without interference, spin up clean environments for each build, and easily test against multiple versions of languages, databases, or services. On top of that, the same image that passes CI can be promoted directly to deployment, shortening the path from commit to production and reducing the risk of environment-related bugs slipping through.
Otii in Docker
On Ubuntu you can run Otii Server in a Docker instance if you make sure to pass through the required system paths from the host system.
First we will show you how an example of how tu run a test script in a Docker container using one Otii device.
And then we will show you how to run multiple containers in parallel on the same Ubuntu server, allocating specific Otii devices to each Docker container.
Dockerfile
Here is an example of a Dockerfile that installs Otii Server and
runs a simple test written in Python.
FROM ubuntu:24.04
RUN apt update && apt install -y curl ca-certificates
ADD https://astral.sh/uv/install.sh /uv-installer.sh
RUN sh /uv-installer.sh && rm /uv-installer.sh
ENV PATH="/root/.local/bin/:$PATH"
COPY docker_test.py /
COPY credentials.json /
COPY otii-server_3.6.4_amd64.deb /
RUN dpkg -i otii-server_3.7.0_amd64.deb
CMD [ "uv", "run", "--script", "/docker_test.py" ]In this example we're using uv to manage Python and its dependencies.
Besides the Dockerfile, we need a credentials.json with the credentials for the
Otii License Server, the install package for the Otii Server and the test script
shown below.
$ tree .
.
├── credentials.json
├── devices.json
├── Dockerfile
├── docker_test.py
├── otii-server_3.6.4_amd64.deb
├── test.py
└── test.sh
1 directory, 7 filesTest script
This is the docker_test.py test script.
The script starts Otii Server before running the test, and stops the server when the test is completed.
import subprocess
import time
from otii_tcp_client import otii_client
MEASURE_TIME = 20
class AppException(Exception):
'''Application Exception'''
def docker_test(otii: otii_client.Connect) -> None:
# Get a reference to a Arc or Ace device
devices = otii.get_devices()
if len(devices) == 0:
raise AppException('No Arc or Ace connected!')
device = devices[0]
print(device.name)
project = otii.get_active_project()
device.set_main_voltage(3.7)
device.enable_channel('mc', True)
project.start_recording()
device.set_main(True)
time.sleep(MEASURE_TIME)
device.set_main(False)
project.stop_recording()
recording = project.get_last_recording()
assert recording is not None
info = recording.get_channel_info(device.id, 'mc')
statistics = recording.get_channel_statistics(device.id, 'mc', info['from'], info['to'])
# Print the statistics
print('Statistics')
print('==========')
print(f'From: {info["from"]} s')
print(f'To: {info["to"]} s')
print(f'Offset: {info["offset"]} s')
print(f'Sample rate: {info["sample_rate"]}')
print('')
print(f'Min: {statistics["min"]:.5} A')
print(f'Max: {statistics["max"]:.5} A')
print(f'Average: {statistics["average"]:.5} A')
print(f'Energy: {statistics["energy"] / 3600:.5} Wh')
print(f'Charge: {statistics["charge"] / 3600:.5} Ah')
def main() -> None:
'''Connect to the Otii 3 application and run the measurement'''
otii_server = subprocess.Popen(['otii_server'])
time.sleep(10)
try:
client = otii_client.OtiiClient()
with client.connect() as otii:
docker_test(otii)
finally:
otii_server.terminate()
otii_server.wait()
if __name__ == '__main__':
main()Building and running the test in Docker
With the Dockerfile and the script above, we can build the docker image and run it:
We need to get Docker access to the Otii device, the /dev/serial and the /run/udev directory
from the host.
In this example the Otii device shows up as /dev/ttyACM0.
sudo docker build -t test .
sudo docker run --device=/dev/ttyACM0 -v /dev/serial:/dev/serial:ro -v /run/udev:/run/udev:ro testRunning multiple containers
Say you want to run multiple containers in parallel, and allocate specific Otii devices in to each container.
A problem in Ubuntu is that the device name is not guaranteed to be the same after for example a reboot.
We need a way to map a device to the current device. If you always keep the same device connected
to the same USB ports on your device, you can instead use the entry in /dev/serial/by-path.
E.g. here is two Otii Arcs connected to an server running Ubuntu 24:
$ ls -l /dev/serial/by-path
total 0
lrwxrwxrwx 1 root root 13 Apr 28 09:59 pci-0000:00:14.0-usb-0:2:1.0 -> ../../ttyACM0
lrwxrwxrwx 1 root root 13 Apr 29 09:51 pci-0000:00:14.0-usb-0:5:1.0 -> ../../ttyACM1
lrwxrwxrwx 1 root root 13 Apr 28 09:59 pci-0000:00:14.0-usbv2-0:2:1.0 -> ../../ttyACM0
lrwxrwxrwx 1 root root 13 Apr 29 09:51 pci-0000:00:14.0-usbv2-0:5:1.0 -> ../../ttyACM1If you disconnect all the devices but one, you get a unique id for this connection. You note the id, and do the same for each device.
Then let's add it to a configuration file calles devices.json:
[{
"name":"Arc 1",
"device_path": "pci-0000:00:14.0-usb-0:2:1.0"
}, {
"name":"Arc 2",
"device_path": "pci-0000:00:14.0-usb-0:5:1.0"
}]The names is just a way to refer them in a python script that we uses to launch a Docker instance with the correct devices.
Now let's right a Python script that uses the configuration above. You use the name as a
parameter to the script and it will start a docker container in the current working directory
with the correct Otii device connected to it:
#!/usr/bin/env python3
import argparse
import json
import os
from pathlib import Path
import subprocess
BYPATH = '/dev/serial/by-path'
DEVICES = 'devices.json'
def run_test(device_name: str) -> None:
paths = [{
'device_path': file,
'device': Path(os.path.join(BYPATH, file)).resolve()
}
for file in os.listdir(BYPATH)
]
with open(DEVICES) as file:
devices = json.load(file)
device_paths = [
device['device_path']
for device in devices
if device['name'] == device_name
]
assert(len(device_paths) == 1)
device_path = device_paths[0]
devices = [
path['device']
for path in paths
if path['device_path'] == device_path
]
assert(len(devices) == 1)
device = devices[0]
subprocess.run([
'docker',
'run',
f'--device={device}',
'-v', '/dev/serial:/dev/serial:ro',
'-v', '/run/udev:/run/udev:ro',
'test'
])
def main() -> None:
parser = argparse.ArgumentParser(description='test')
parser.add_argument(
'-n',
'--device-name',
required=True,
dest='name',
help='Device name',
)
args = parser.parse_args()
device_name = args.name
run_test(device_name)
if __name__ == '__main__':
main()
We can now start the Docker container with the correct device connected to it:
$ sudo ./test.py --device-name 'Arc 1'