在 Docker 环境中使用 Otii 进行自动化测试

这些功能需要 Automation Toolbox(自动化工具箱) 软件许可证。

Docker 容器在持续集成(CI)系统中具有极高的价值,因为它们能够在流水线的每个阶段都能提供一致且可复现的环境。通过将应用程序及其依赖项、运行时和配置打包到一个镜像中,容器彻底解决了经典的“在我的电脑上能运行”的问题——无论是在开发者的笔记本电脑、CI 运行节点(Runner),还是在类似于生产环境的预发布(Staging)服务器上,构建和测试的行为都完全一致。

此外,容器启动速度极快且相互隔离,这使得 CI 系统能够并行运行多个任务而互不干扰,为每次构建启动干净独立的环境,并能轻松针对多种版本的语言、数据库或服务进行兼容性测试。最重要的是,通过 CI 验证的同一个镜像可以直接晋升并部署到生产环境中,缩短了从提交到上线的路径,降低了因环境差异导致 Bug 漏检的风险。

在 Docker 中使用Otii

在 Ubuntu 系统上,只要确保将宿主机(Host)中系统必需的路径透传过去,用户就可以在 Docker 实例中运行Otii Server服务器。

  • 首先,我们将向您展示如何在 Docker 容器中使用一台 Otii 设备运行测试脚本的示例。
  • 随后,我们将向您展示如何在同一台 Ubuntu 服务器上并行运行多个容器,并为每个 Docker 容器分配特定的 Otii 设备。

Dockerfile 文件

以下是一个安装了 Otii Server 并运行由 Python 编写的简单测试的 Dockerfile 示例。

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" ]

在此示例中,我们使用uv来管理 Python 及其依赖项。 除了 Dockerfile 之外,我们还需要credentials.json文件,它要包含 Otii 许可证服务器凭据;Otii Server 服务器的安装包以及如下所示的测试脚本。

$ tree .
.
├── credentials.json
├── devices.json
├── Dockerfile
├── docker_test.py
├── otii-server_3.6.4_amd64.deb
├── test.py
└── test.sh

1 directory, 7 files

测试脚本

这是 docker_test.py 测试脚本。 该脚本在运行测试之前会先启动 Otii Server,并在测试完成后关闭该服务器。

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()

在 Docker 中构建并运行测试

准备好上述 Dockerfile 和脚本后,我们就可以构建 Docker 镜像并运行它: 我们需要将宿主机上的 Otii 设备、/dev/serial 以及 /run/udev 目录透传(或挂载)给 Docker 容器。 在此示例中,Otii 设备在系统中体现为 /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 test

运行多个容器

假设用户希望并行运行多个容器,并为每个容器分配特定的 Otii 设备。 Ubuntu 系统中的一个核心问题是,在重启等操作后,设备名称(ttyACMX)无法保证保持不变。 因此,我们需要一种方法将物理设备固定映射到当前的设备节点。如果用户始终将设备连接到相同的 USB 端口上,则可以使用 /dev/serial/by-path 中的条目来替代。

例如,以下是连接到运行 Ubuntu 24 系统的服务器上的两台 Otii Arc 设备:

$ 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 -> ../../ttyACM1

如果用户每次只断开除一台之外的所有设备,您就能获得该连接通道的唯一 ID。记录下这个 ID,并对每台设备重复此操作。然后,我们将这些信息添加到名为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"
}]

这里的名称(name)只是一种在 Python 脚本中引用它们的方法,我们用该脚本来启动挂载了正确设备的 Docker 实例。 现在,让我们编写一个使用上述配置的Python 脚本。您将设备名称作为参数传递给该脚本,它就会在当前工作目录下启动一个 Docker 容器,并将正确的 Otii 设备连接(挂载)到该容器中:

#!/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()

现在,我们就可以在连接了正确设备的情况下启动 Docker 容器了。

$ sudo ./test.py --device-name 'Arc 1'