mirror of
https://github.com/espressif/esp-modbus.git
synced 2026-08-03 20:24:09 +02:00
1042 lines
37 KiB
Python
1042 lines
37 KiB
Python
# SPDX-FileCopyrightText: 2025-2026 Espressif Systems (Shanghai) CO LTD
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
# pylint: disable=W0621 # redefined-outer-name
|
|
|
|
import logging
|
|
import os
|
|
import sys
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
from statistics import mean
|
|
from typing import Any, Callable, Dict, Generator, Optional, Tuple, List, Union
|
|
from re import Match
|
|
from dataclasses import dataclass
|
|
|
|
import pexpect
|
|
import pytest
|
|
from _pytest.fixtures import FixtureRequest
|
|
from _pytest.monkeypatch import MonkeyPatch
|
|
from pytest_embedded.plugin import multi_dut_argument, multi_dut_fixture
|
|
from pytest_embedded_idf.app import IdfApp
|
|
from pytest_embedded_idf.dut import IdfDut
|
|
from pytest_embedded_idf.serial import IdfSerial
|
|
from pytest_embedded_idf import CaseTester
|
|
|
|
|
|
class Stages(Enum):
|
|
STACK_DEFAULT = 1
|
|
STACK_IPV4 = 2
|
|
STACK_IPV6 = 3
|
|
STACK_INIT = 4
|
|
STACK_CONNECT = 5
|
|
STACK_START = 6
|
|
STACK_PAR_OK = 7
|
|
STACK_PAR_FAIL = 8
|
|
STACK_OBJECT_CREATE = 9
|
|
STACK_DESTROY = 10
|
|
STACK_BAD_CONNECTION = 11
|
|
STACK_CID_RESPONSE_TIME = 12
|
|
|
|
|
|
## Object Fields
|
|
TRANSACTION_TIMESTAMP = 0
|
|
OBJ_TAG = 1
|
|
OBJ_ID = 2
|
|
|
|
## Parameters Fields
|
|
OBJ_ADDRESS = 1
|
|
CID = 2
|
|
PARAM_NAME = 3
|
|
PARAM_VAL = 4
|
|
PARAM_SUCCESS = "success"
|
|
PARAM_FAIL = "fail"
|
|
|
|
## Getter tags for readability
|
|
MASTER_TAG = "master"
|
|
SLAVE_TAG = "slave"
|
|
|
|
DEFAULT_SDKCONFIG = "default"
|
|
ALLOWED_PERCENT_OF_FAILS = 10
|
|
|
|
|
|
## Dataclass for structuring data to plot statistical graph
|
|
@dataclass
|
|
class ModbusDutStats:
|
|
success_stats: List[int]
|
|
fail_stats: List[int]
|
|
|
|
|
|
class MbRequestResponse:
|
|
def __init__(
|
|
self,
|
|
transaction_timestamp: int,
|
|
master_inst_address: bytes,
|
|
cid: bytes,
|
|
) -> None:
|
|
self.transaction_timestamp: int = transaction_timestamp
|
|
self.master_inst_address: bytes = master_inst_address
|
|
self.cid: bytes = cid
|
|
|
|
|
|
class MbParameter:
|
|
def __init__(
|
|
self,
|
|
name: Union[str, bytes],
|
|
instance_address: bytes,
|
|
transaction_timestamp: int,
|
|
obj_tag: str,
|
|
status: str,
|
|
cid: bytes,
|
|
response_time: Optional[int],
|
|
value: Optional[bytes],
|
|
) -> None:
|
|
if isinstance(name, (bytes, bytearray)):
|
|
self.name: str = name.decode("ascii")
|
|
else:
|
|
self.name = str(name)
|
|
self.instance_address: bytes = instance_address
|
|
self.transaction_timestamp: int = transaction_timestamp
|
|
self.obj_tag: str = obj_tag
|
|
self.status: str = status
|
|
self.cid: bytes = cid
|
|
self.response_time: Optional[int] = response_time
|
|
self.value: Optional[bytes] = value
|
|
|
|
def __repr__(self) -> str:
|
|
if self.obj_tag == MASTER_TAG:
|
|
return (
|
|
f"Parameter name:{self.name}, Obj tag: {self.obj_tag}, Object ID:{self.instance_address!r}, "
|
|
f"Transaction time:{self.transaction_timestamp}, Status:{self.status}, Cid:{self.cid!r}, "
|
|
f"Value: {self.value!r}, Response time:{self.response_time}"
|
|
)
|
|
else:
|
|
try:
|
|
addr = self.instance_address.decode("ascii")
|
|
except Exception:
|
|
addr = repr(self.instance_address)
|
|
return f"Parameter name:{self.name}, Obj tag: {self.obj_tag}, Object ID:{addr}, Transaction time:{self.transaction_timestamp}, Status:{self.status}"
|
|
|
|
def get_name(self) -> str:
|
|
"""Retrieve parameter name (string)."""
|
|
return self.name
|
|
|
|
def get_inst(self) -> bytes:
|
|
"""Retrieve parameter instance address (bytes)."""
|
|
return self.instance_address
|
|
|
|
def get_timestamp(self) -> int:
|
|
"""Retrieve parameter transaction timestamp (int)."""
|
|
return self.transaction_timestamp
|
|
|
|
def get_tag(self) -> str:
|
|
"""Retrieve parameter tag (string)."""
|
|
return self.obj_tag
|
|
|
|
def get_status(self) -> str:
|
|
"""Retrieve parameter status (string)."""
|
|
return self.status
|
|
|
|
def get_cid(self) -> bytes:
|
|
"""Retrieve parameter cid (bytes)."""
|
|
return self.cid
|
|
|
|
def get_response_time(self) -> Optional[int]:
|
|
"""Retrieve parameter response time (ms) or None."""
|
|
return self.response_time
|
|
|
|
def get_value(self) -> Optional[bytes]:
|
|
"""Retrieve parameter value (bytes) or a sentinel b\"N/A\"."""
|
|
return self.value if self.value is not None else b"N/A"
|
|
|
|
|
|
class MbObject:
|
|
def __init__(self, tag: str, id: bytes, object_creation_timestamp: bytes) -> None:
|
|
self.tag: str = tag
|
|
self.id: bytes = id
|
|
self.object_creation_timestamp: bytes = object_creation_timestamp
|
|
self.parameters: List[MbParameter] = []
|
|
self.parameter_count: int = 0
|
|
|
|
def __repr__(self) -> str:
|
|
try:
|
|
obj_id = self.id.decode("ascii")
|
|
except Exception:
|
|
obj_id = repr(self.id)
|
|
try:
|
|
ts = self.object_creation_timestamp.decode("ascii")
|
|
except Exception:
|
|
ts = repr(self.object_creation_timestamp)
|
|
return f"Obj Tag: {self.tag}, Obj ID: {obj_id}, Creation Timestamp: {ts}"
|
|
|
|
def add_parameter(
|
|
self,
|
|
name: bytes,
|
|
instance_address: bytes,
|
|
transaction_timestamp: bytes,
|
|
tag: str,
|
|
status: str,
|
|
cid: bytes,
|
|
value: Optional[bytes],
|
|
) -> MbParameter:
|
|
"""The function add to list master or slave parameters"""
|
|
parameter: MbParameter = MbParameter(
|
|
name,
|
|
instance_address,
|
|
int(transaction_timestamp.decode("ascii")),
|
|
tag,
|
|
status,
|
|
cid,
|
|
None,
|
|
value,
|
|
)
|
|
self.parameters.append(parameter)
|
|
self.parameter_count += 1
|
|
return parameter
|
|
|
|
def is_master(self) -> bool:
|
|
"""The function checks for master tag in instance object"""
|
|
if MASTER_TAG in self.tag:
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
def is_slave(self) -> bool:
|
|
"""The function checks for slave tag in instance object"""
|
|
if SLAVE_TAG in self.tag:
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
|
|
class ModbusTestDut(IdfDut):
|
|
TEST_START_PROMPT = r"I\s\(([0-9]+)\) mb_console: (Start modbus instances)"
|
|
TEST_IP_PROMPT = r"Waiting IP\(([0-9]{1,2})\) from stdin:"
|
|
TEST_IP_ADDRESS_REGEXP = r"I \([0-9]+\) [a-z_]+: [A-Za-z\-]* IPv4 [A-Za-z\"_:\s]*address: ([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})"
|
|
TEST_APP_NAME = r"I \([0-9]+\) [a-z_]+: Project name:\s+([_a-z]*)"
|
|
|
|
TEST_EXPECT_STR_TIMEOUT = 120
|
|
TEST_PROMPT_TOUT = 10
|
|
TEST_ACK_TIMEOUT = 60
|
|
TEST_MAX_CIDS = 8
|
|
|
|
app: IdfApp
|
|
serial: IdfSerial # type: ignore[override]
|
|
|
|
def __init__(self, *args, **kwargs) -> None: # type: ignore
|
|
super().__init__(*args, **kwargs)
|
|
self.logger: logging.Logger = logging.getLogger()
|
|
self.ip_address: Optional[str] = None
|
|
self.app_name: Optional[str] = None
|
|
self.dut_list: Optional[List[ModbusTestDut]] = None
|
|
self.param_fail_count: int = 0
|
|
self.param_ok_count: int = 0
|
|
self.test_stage: Stages = Stages.STACK_DEFAULT
|
|
self.dictionary: Optional[Dict[Stages, bytes]] = None
|
|
self.test_finish: bool = False
|
|
self.mb_objects_count: int = (
|
|
0 # number of objects (modbus instances in the test application)
|
|
)
|
|
self.mb_objects: List[MbObject] = []
|
|
self.mb_request_response: List[MbRequestResponse] = []
|
|
|
|
def close(self) -> None:
|
|
super().close()
|
|
|
|
def check_mb_objects_list(self) -> None:
|
|
"""Method to check if mb_objects list is not empty"""
|
|
if not self.mb_objects:
|
|
self.logger.error("list of modbus objects in DUT couldn't be retrieved")
|
|
raise RuntimeError from None
|
|
|
|
for objects in self.mb_objects:
|
|
if objects is None:
|
|
self.logger.error("list of modbus objects in DUT is wrongly populated")
|
|
raise RuntimeError from None
|
|
|
|
return None
|
|
|
|
def validate_object_creation_tag(self, parsed_obj_tag: str) -> str:
|
|
"""Function checking and updating object creation tag master/slave if wrong"""
|
|
if self.app_name is None:
|
|
self.logger.error("app_name not initialized; cannot validate object tag")
|
|
raise RuntimeError from None
|
|
|
|
if MASTER_TAG in parsed_obj_tag or SLAVE_TAG in parsed_obj_tag:
|
|
return parsed_obj_tag
|
|
|
|
# Workaround to get master/slave tag from DUT class
|
|
# Checking if app_name contains the field. Ex: modbus_tcp_master
|
|
obj_tag: str = ""
|
|
if MASTER_TAG in self.app_name.lower():
|
|
obj_tag = MASTER_TAG
|
|
elif SLAVE_TAG in self.app_name.lower():
|
|
obj_tag = SLAVE_TAG
|
|
else:
|
|
self.logger.error("Could not determine master/slave tag from app_name")
|
|
raise RuntimeError from None
|
|
|
|
self.logger.info(f"Object tag wrongly parsed, new tag being saved: {obj_tag}")
|
|
return obj_tag
|
|
|
|
def add_object(self, tag: str, id: bytes, timestamp: bytes) -> MbObject:
|
|
"""The function add to list master or slave instances in the test"""
|
|
obj: MbObject = MbObject(tag, id, timestamp)
|
|
self.mb_objects.append(obj)
|
|
self.mb_objects_count += 1
|
|
return obj
|
|
|
|
def get_objects_by_tag(self, tag: str) -> List[MbObject]:
|
|
"""The getter retrieves object by master or slave tag"""
|
|
objects: List[MbObject] = []
|
|
self.check_mb_objects_list()
|
|
|
|
for obj in self.mb_objects:
|
|
if tag == obj.tag:
|
|
objects.append(obj)
|
|
|
|
if not objects:
|
|
self.logger.error("objects list from tag couldn't be retrieved")
|
|
raise RuntimeError from None
|
|
else:
|
|
return objects
|
|
|
|
def get_object_by_id(self, id: bytes) -> Optional[MbObject]:
|
|
"""The getter retrieves master or slave object by instance address"""
|
|
self.check_mb_objects_list()
|
|
for obj in self.mb_objects:
|
|
if id == obj.id:
|
|
return obj
|
|
|
|
self.logger.error(f"couldn't find registered object with id: {id!r}")
|
|
return None
|
|
|
|
def update_wrong_object_id(self, id: bytes) -> Optional[MbObject]:
|
|
"""Scan registered object list checking for a wrongly parsed object ID which is
|
|
not 10 characters long. Ex - 0x3ffbf7bc"""
|
|
self.check_mb_objects_list()
|
|
for obj in self.mb_objects:
|
|
if len(obj.id) != 10:
|
|
self.logger.info(f"Updating wrong object id: {obj.id!r} to: {id!r}")
|
|
obj.id = id
|
|
return obj
|
|
return None
|
|
|
|
def get_params_by_name(self, name: str) -> Optional[List[MbParameter]]:
|
|
"""The getter retrieves parameters by name"""
|
|
# import sys, pdb; pdb.Pdb(stdout=sys.__stdout__).set_trace()
|
|
self.check_mb_objects_list()
|
|
params: List[MbParameter] = []
|
|
for obj in self.mb_objects:
|
|
for param in obj.parameters:
|
|
if name in str(param.name):
|
|
params.append(param)
|
|
return params
|
|
|
|
def get_params_by_object_id_and_status(
|
|
self, instance_address: bytes, status: str
|
|
) -> Optional[List[MbParameter]]:
|
|
"""The getter retrieves parameters by instance address of object and status of request"""
|
|
self.check_mb_objects_list()
|
|
for obj in self.mb_objects:
|
|
if obj.id == instance_address:
|
|
return [
|
|
param
|
|
for param in obj.parameters
|
|
if param.instance_address == instance_address
|
|
and param.status == status
|
|
]
|
|
return None
|
|
|
|
def get_master_params_by_status(self, status: str) -> List[MbParameter]:
|
|
"""The getter retrieves master parameters by status request"""
|
|
all_master_params: List[MbParameter] = []
|
|
self.check_mb_objects_list()
|
|
for obj in self.mb_objects:
|
|
if obj.is_master():
|
|
all_master_params.extend(
|
|
[
|
|
param
|
|
for param in obj.parameters
|
|
if param.status == status and param.obj_tag == obj.tag
|
|
]
|
|
)
|
|
return all_master_params
|
|
|
|
def get_slave_params_by_status(self, status: str) -> List[MbParameter]:
|
|
"""The getter retrieves slave parameters by status of request"""
|
|
all_slave_params: List[MbParameter] = []
|
|
self.check_mb_objects_list()
|
|
for obj in self.mb_objects:
|
|
if obj.is_slave():
|
|
all_slave_params.extend(
|
|
[
|
|
param
|
|
for param in obj.parameters
|
|
if param.status == status and param.obj_tag == obj.tag
|
|
]
|
|
)
|
|
return all_slave_params
|
|
|
|
def get_params_by_timestamp_range(
|
|
self, start: int, finish: int
|
|
) -> List[MbParameter]:
|
|
"""The getter retrieves parameters by timestamp"""
|
|
params_by_timestamp: List[MbParameter] = []
|
|
self.check_mb_objects_list()
|
|
for obj in self.mb_objects:
|
|
if obj is not None:
|
|
params_by_timestamp.extend(
|
|
[
|
|
param
|
|
for param in obj.parameters
|
|
if start <= param.transaction_timestamp <= finish
|
|
]
|
|
)
|
|
return params_by_timestamp
|
|
|
|
def add_dut_list(self, dut_instance: "ModbusTestDut") -> None:
|
|
"""The function keeps track of all DUTs involved in the test"""
|
|
if self.dut_list is None:
|
|
self.dut_list = []
|
|
|
|
self.dut_list.append(dut_instance)
|
|
return None
|
|
|
|
def send_message_destroy_other_dut_instances(self) -> None:
|
|
"""The function sends message to end all DUTs"""
|
|
if self.dut_list is not None:
|
|
for dut_instance in self.dut_list:
|
|
self.logger.info("Sending destroy message to others DUT Instances")
|
|
dut_instance.write("mb stop instances\n")
|
|
return None
|
|
|
|
def get_avg_response_time_master(self) -> int:
|
|
"""The function iterates over master parameters to calculate mean response time"""
|
|
master_success_params: List[MbParameter] = self.get_master_params_by_status(
|
|
PARAM_SUCCESS
|
|
)
|
|
if master_success_params:
|
|
avg_response_time: List[int] = [
|
|
param.response_time
|
|
for param in master_success_params
|
|
if param.response_time is not None
|
|
]
|
|
if avg_response_time:
|
|
return int(mean(avg_response_time))
|
|
|
|
self.logger.info("Master Response time list couldn't be properly retrieved")
|
|
return 0
|
|
|
|
def send_message_start_dut(self) -> bool:
|
|
"""The function sends message to start DUT sequence"""
|
|
result: bool = True
|
|
self.logger.info(f"Sending start message to {self.dut_get_name()}")
|
|
self.write("mb start instances\n")
|
|
try:
|
|
self.expect(self.TEST_START_PROMPT, timeout=2)
|
|
except pexpect.TIMEOUT:
|
|
self.logger.error("Timeout waiting for start prompt confirmation.")
|
|
result = False
|
|
return result
|
|
|
|
def send_message_destroy_dut(self) -> None:
|
|
"""The function sends message to end caller DUT"""
|
|
self.logger.info("Sending destroy message to this DUT")
|
|
self.write("mb stop instances\n")
|
|
return None
|
|
|
|
def add_request_response(
|
|
self, transaction_timestamp: bytes, master_address: bytes, cid: bytes
|
|
) -> None:
|
|
"""The function gets and saves master requests"""
|
|
request_response: MbRequestResponse = MbRequestResponse(
|
|
int(transaction_timestamp.decode("ascii")), master_address, cid
|
|
)
|
|
self.mb_request_response.append(request_response)
|
|
return None
|
|
|
|
def add_response_time_to_param(self, param: MbParameter) -> None:
|
|
"""The function add response time to parameter entry based on request list"""
|
|
last_request_response: MbRequestResponse = self.mb_request_response[-1]
|
|
if (
|
|
param.instance_address == last_request_response.master_inst_address
|
|
and param.cid == last_request_response.cid
|
|
and param.transaction_timestamp
|
|
>= last_request_response.transaction_timestamp
|
|
):
|
|
param.response_time = (
|
|
param.transaction_timestamp
|
|
- last_request_response.transaction_timestamp
|
|
)
|
|
self.logger.info(
|
|
"Response time:%d for Cid:%s from Master:%s",
|
|
param.response_time,
|
|
param.cid,
|
|
param.instance_address,
|
|
)
|
|
return None
|
|
|
|
def dut_get_ip(self) -> Optional[str]:
|
|
"""The function gets IP address from log"""
|
|
if self.ip_address is None:
|
|
expect_address = self.expect(
|
|
self.TEST_IP_ADDRESS_REGEXP, timeout=self.TEST_EXPECT_STR_TIMEOUT
|
|
)
|
|
if isinstance(expect_address, Match):
|
|
self.ip_address = expect_address.group(1).decode("ascii")
|
|
return self.ip_address
|
|
|
|
def dut_get_name(self) -> Optional[str]:
|
|
"""The function gets project name from log"""
|
|
if self.app_name is None:
|
|
expect_name = self.expect(
|
|
self.TEST_APP_NAME, timeout=self.TEST_EXPECT_STR_TIMEOUT
|
|
)
|
|
if isinstance(expect_name, Match):
|
|
self.app_name = expect_name.group(1).decode("ascii")
|
|
return self.app_name
|
|
|
|
def dut_send_ip(
|
|
self, slave_ip: Optional[str] = None, port: Optional[str] = None
|
|
) -> Optional[int]:
|
|
"""The function sends the slave IP address defined as a parameter to master"""
|
|
addr_num: int = 0
|
|
try:
|
|
self.expect(self.TEST_IP_PROMPT, timeout=self.TEST_PROMPT_TOUT)
|
|
except pexpect.TIMEOUT:
|
|
# Workaround for unreliable parsing of the master prompt.
|
|
# The expect() sometime does not catch it in spite it appears in the log.
|
|
# Send the IP address anyway after the timeout.
|
|
self.logger.error(
|
|
"Timeout waiting for IP prompt. Try to send address anyway."
|
|
)
|
|
if isinstance(slave_ip, str):
|
|
for addr_num in range(0, self.TEST_MAX_CIDS):
|
|
message: str = r"IP {}={}".format(addr_num, slave_ip)
|
|
if isinstance(port, str) or isinstance(port, int):
|
|
message += r";{}".format(str(port))
|
|
message += r"\r\n"
|
|
self.write(message)
|
|
self.logger.info("{} sent to master".format(message))
|
|
return addr_num
|
|
|
|
def dut_stats_info(self) -> ModbusDutStats:
|
|
"""The function retrieves all success and fail parameters per DUT to plot stats graph"""
|
|
all_success_params: List[int] = []
|
|
all_fail_params: List[int] = []
|
|
|
|
self.check_mb_objects_list()
|
|
|
|
for obj in self.mb_objects:
|
|
temp_success_params: Optional[List[MbParameter]] = (
|
|
self.get_params_by_object_id_and_status(obj.id, PARAM_SUCCESS)
|
|
)
|
|
if temp_success_params is not None:
|
|
all_success_params.append(len(temp_success_params))
|
|
else:
|
|
self.logger.error(
|
|
"Couldn't retrieve success parameters for object %s", obj.id
|
|
)
|
|
all_success_params.append(
|
|
0
|
|
) # propagating the None error to raise exception
|
|
|
|
temp_fail_params: Optional[List[MbParameter]] = (
|
|
self.get_params_by_object_id_and_status(obj.id, PARAM_FAIL)
|
|
)
|
|
if temp_fail_params is not None:
|
|
all_fail_params.append(len(temp_fail_params))
|
|
else:
|
|
self.logger.error("Fail parameters couldn't be retrieved to plot graph")
|
|
raise RuntimeError from None
|
|
|
|
if (
|
|
all(all_success_params) is False or not all_success_params
|
|
): # Checking only success parameters. Slaves dont have fail parameters, they return a zero list.
|
|
self.logger.error("success parameters couldn't be retrieved to plot graph")
|
|
raise RuntimeError from None
|
|
else:
|
|
return ModbusDutStats(all_success_params, all_fail_params)
|
|
|
|
def plot_modbus_stats(
|
|
self,
|
|
master_objects: List[MbObject],
|
|
master_all_params: ModbusDutStats,
|
|
slave_objects: List[MbObject],
|
|
slave_all_params: ModbusDutStats,
|
|
file_name: str,
|
|
) -> None:
|
|
"""The function plots graph from all success and fail parameters"""
|
|
|
|
# Lazy import to comply with idf-ci build job import rules
|
|
import numpy as np
|
|
import matplotlib.pyplot as plt
|
|
from pathlib import Path
|
|
|
|
saving_path_dir = Path(self.app.app_path).parent # Dir for graph to be saved
|
|
names: List[str] = []
|
|
width = 0.1
|
|
alpha = 0.5
|
|
right = 0.55
|
|
x_position_text = 1.03
|
|
y_position_text = 0.98
|
|
label_space = 0.3
|
|
|
|
for obj in master_objects:
|
|
names.append(f"Master \n {obj.id.decode()}")
|
|
|
|
for obj in slave_objects:
|
|
names.append(f"Slave \n {obj.id.decode()}")
|
|
|
|
label_axis = np.arange(len(names)) * label_space
|
|
|
|
text_box: str = (
|
|
f"Master Average response time:{self.get_avg_response_time_master()} ms"
|
|
)
|
|
|
|
fig, ax = plt.subplots()
|
|
plt.bar(
|
|
label_axis,
|
|
(master_all_params.success_stats + slave_all_params.success_stats),
|
|
width=width,
|
|
color="green",
|
|
label="Success",
|
|
)
|
|
plt.bar(
|
|
(label_axis + width),
|
|
(master_all_params.fail_stats + slave_all_params.fail_stats),
|
|
width=width,
|
|
color="red",
|
|
label="Fail",
|
|
)
|
|
plt.xticks(label_axis, names)
|
|
props: Dict[str, Union[str, float]] = dict(
|
|
boxstyle="round", facecolor="wheat", alpha=alpha
|
|
)
|
|
ax.text(
|
|
x_position_text,
|
|
y_position_text,
|
|
text_box,
|
|
transform=ax.transAxes,
|
|
fontsize=12,
|
|
verticalalignment="top",
|
|
bbox=props,
|
|
)
|
|
plt.subplots_adjust(right=right)
|
|
plt.legend()
|
|
plt.title("Modbus Test Statistics")
|
|
plt.ylabel("Parameters accessed")
|
|
plt.savefig(Path.joinpath(saving_path_dir, f"{file_name}.png"))
|
|
plt.show()
|
|
|
|
def get_item(self, data: Optional[List[Any]] = None, item: int = 0) -> bytes:
|
|
"""
|
|
Safely return the requested item from a list as bytes.
|
|
On unexpected exceptions returns a bytes sentinel b"N/A".
|
|
"""
|
|
try:
|
|
if not data or len(data) <= item:
|
|
# Return sentinel when data missing to keep callers' expectations of bytes
|
|
return b"N/A"
|
|
val = data[item]
|
|
if isinstance(val, bytes):
|
|
return val
|
|
if isinstance(val, str):
|
|
return val.encode("utf-8")
|
|
if isinstance(val, (int, float)):
|
|
return str(val).encode("utf-8")
|
|
# Fallback: stringify and encode any other object
|
|
return str(val).encode("utf-8")
|
|
except Exception:
|
|
return b"N/A"
|
|
|
|
def expect_any(
|
|
self, *expect_items: Tuple[Optional[str], Callable], timeout: Optional[int]
|
|
) -> None:
|
|
"""
|
|
expect_any(*expect_items, timeout=DEFAULT_TIMEOUT)
|
|
expect any of the patterns.
|
|
will call callback (if provided) if pattern match succeed and then return.
|
|
will pass match result to the callback.
|
|
|
|
:raise ExpectTimeout: failed to match any one of the expect items before timeout
|
|
:raise UnsupportedExpectItem: pattern in expect_item is not string or compiled RegEx
|
|
|
|
:arg expect_items: one or more expect items.
|
|
string, compiled RegEx pattern or (string or RegEx(string pattern), callback)
|
|
:keyword timeout: timeout for expect
|
|
:return: matched item
|
|
"""
|
|
|
|
def process_expected_item(
|
|
item_raw: Tuple[Optional[str], Callable[..., Any]],
|
|
) -> Dict[str, Any]:
|
|
# convert item raw data to standard dict
|
|
item = {
|
|
"pattern": item_raw[0]
|
|
if isinstance(item_raw, tuple)
|
|
else item_raw or None,
|
|
"callback": item_raw[1] if isinstance(item_raw, tuple) else None,
|
|
"index": -1,
|
|
"ret": None,
|
|
}
|
|
return item
|
|
|
|
expect_items_list: List[Dict[str, Any]] = [
|
|
process_expected_item(item)
|
|
for item in expect_items
|
|
if isinstance(item, tuple) and item[0]
|
|
]
|
|
expect_patterns: List[Any] = [
|
|
item["pattern"] for item in expect_items_list if item["pattern"] is not None
|
|
]
|
|
match_item: Optional[Dict[str, Any]] = None
|
|
|
|
if self.pexpect_proc is not None:
|
|
match_index: int = self.pexpect_proc.expect(expect_patterns, timeout)
|
|
|
|
if isinstance(match_index, int):
|
|
match_item = expect_items_list[match_index] # type: ignore
|
|
match_item["index"] = match_index # type: ignore # keep match index
|
|
if (
|
|
isinstance(self.pexpect_proc.match, Match)
|
|
and len(self.pexpect_proc.match.groups()) > 0
|
|
):
|
|
match_item["ret"] = self.pexpect_proc.match.groups()
|
|
if match_item["callback"]:
|
|
match_item["callback"](
|
|
match_item["ret"]
|
|
) # execution of callback function
|
|
else:
|
|
self.logger.error(
|
|
"%s: failed to parse output. Please check component versions.",
|
|
self.app_name,
|
|
)
|
|
raise RuntimeError from None
|
|
|
|
def dut_test_start(
|
|
self, dictionary: Dict, timeout_value: Optional[int] = TEST_EXPECT_STR_TIMEOUT
|
|
) -> None: # type: ignore
|
|
"""The method to initialize and handle test stages"""
|
|
|
|
def handle_get_ip4(data: Optional[Any] = None) -> None:
|
|
"""Handle get_ip v4"""
|
|
# Synch for handling the case where the DUT dont stablish connection from the beginning
|
|
# DUT reboot after failing reconnecting
|
|
if self.test_stage.value > Stages.STACK_IPV4.value:
|
|
self.logger.info(
|
|
f"Handle: {self.app_name}: Reboot loop detected on stage [{Stages.STACK_IPV4.name}]: {str(data)}"
|
|
)
|
|
self.send_message_destroy_other_dut_instances()
|
|
self.send_message_destroy_dut()
|
|
self.test_finish = True
|
|
else:
|
|
self.test_stage = Stages.STACK_IPV4
|
|
self.logger.info(
|
|
f"Handle: {self.app_name}[{self.test_stage.name}]: {str(data)}"
|
|
)
|
|
|
|
def handle_get_ip6(data: Optional[Any] = None) -> None:
|
|
"""Handle get_ip v6"""
|
|
self.test_stage = Stages.STACK_IPV6
|
|
self.logger.info(
|
|
f"Handle: {self.app_name}[{self.test_stage.name}]: {str(data)}"
|
|
)
|
|
|
|
def handle_init(data: Optional[Any] = None) -> None:
|
|
"""Handle init"""
|
|
self.test_stage = Stages.STACK_INIT
|
|
self.logger.info(
|
|
f"Handle: {self.app_name}[{self.test_stage.name}]: {str(data)}"
|
|
)
|
|
|
|
def handle_connect(data: Optional[Any] = None) -> None:
|
|
"""Handle connect"""
|
|
self.test_stage = Stages.STACK_CONNECT
|
|
self.logger.info(
|
|
f"Handle: {self.app_name}[{self.test_stage.name}]: {str(data)}"
|
|
)
|
|
|
|
def handle_test_start(data: Optional[Any] = None) -> None:
|
|
"""Handle connect"""
|
|
self.test_stage = Stages.STACK_START
|
|
self.logger.info(
|
|
f"Handle: {self.app_name}[{self.test_stage.name}]: {str(data)}"
|
|
)
|
|
|
|
def handle_cid_response_time(data: Optional[Any] = None) -> None:
|
|
"""Handle Cid sent request for response time calculation"""
|
|
self.test_stage = Stages.STACK_CID_RESPONSE_TIME
|
|
self.logger.info(
|
|
f"Handle: {self.app_name}[{self.test_stage.name}]: {str(data)}"
|
|
)
|
|
self.add_request_response(
|
|
self.get_item(data, TRANSACTION_TIMESTAMP),
|
|
self.get_item(data, OBJ_ADDRESS),
|
|
self.get_item(data, CID),
|
|
)
|
|
|
|
def handle_bad_connection(data: Optional[Any] = None) -> None:
|
|
"""Handle bad connection"""
|
|
self.test_stage = Stages.STACK_BAD_CONNECTION
|
|
self.logger.info(
|
|
f"Handle: {self.app_name}: Reached the stage [{self.test_stage.name}]. Ending the test."
|
|
)
|
|
self.send_message_destroy_other_dut_instances()
|
|
self.send_message_destroy_dut()
|
|
self.test_finish = True
|
|
|
|
def handle_par_ok(data: Optional[Any] = None) -> None:
|
|
"""Handle parameter ok"""
|
|
self.test_stage = Stages.STACK_PAR_OK
|
|
self.logger.info(
|
|
f"Handle: {self.app_name}[{self.test_stage.name}]: {str(data)}"
|
|
)
|
|
# Checking if MBobject exist in the object list
|
|
|
|
object_handle: Optional[MbObject] = self.get_object_by_id(
|
|
self.get_item(data, OBJ_ADDRESS)
|
|
)
|
|
if object_handle is None:
|
|
object_handle = self.update_wrong_object_id(
|
|
self.get_item(data, OBJ_ADDRESS)
|
|
)
|
|
assert object_handle is not None
|
|
|
|
last_sucess_parameter: MbParameter = object_handle.add_parameter(
|
|
self.get_item(data, PARAM_NAME),
|
|
self.get_item(data, OBJ_ADDRESS),
|
|
self.get_item(data, TRANSACTION_TIMESTAMP),
|
|
object_handle.tag,
|
|
PARAM_SUCCESS,
|
|
self.get_item(data, CID),
|
|
self.get_item(data, PARAM_VAL),
|
|
)
|
|
if object_handle.is_master():
|
|
self.add_response_time_to_param(
|
|
last_sucess_parameter
|
|
) # adding to parameter the response time for this request
|
|
self.logger.info(last_sucess_parameter)
|
|
else:
|
|
self.logger.info(last_sucess_parameter)
|
|
|
|
self.param_ok_count += 1
|
|
|
|
def handle_par_fail(data: Optional[Any]) -> None:
|
|
"""Handle parameter fail"""
|
|
self.test_stage = Stages.STACK_PAR_FAIL
|
|
self.logger.info(
|
|
f"Handle: {self.app_name}[{self.test_stage.name}]: {str(data)}"
|
|
)
|
|
# Checking if MBobject exist in the object list
|
|
object_handle: Optional[MbObject] = self.get_object_by_id(
|
|
self.get_item(data, OBJ_ADDRESS)
|
|
)
|
|
if object_handle is None:
|
|
object_handle = self.update_wrong_object_id(
|
|
self.get_item(data, OBJ_ADDRESS)
|
|
)
|
|
assert object_handle is not None
|
|
|
|
last_fail_parameter: MbParameter = object_handle.add_parameter(
|
|
self.get_item(data, PARAM_NAME),
|
|
self.get_item(data, OBJ_ADDRESS),
|
|
self.get_item(data, TRANSACTION_TIMESTAMP),
|
|
object_handle.tag,
|
|
PARAM_FAIL,
|
|
self.get_item(data, CID),
|
|
None,
|
|
)
|
|
self.logger.info(last_fail_parameter)
|
|
|
|
self.param_fail_count += 1
|
|
|
|
def handle_obj_create(data: Optional[Any]) -> None:
|
|
"""Handle creation"""
|
|
self.test_stage = Stages.STACK_OBJECT_CREATE
|
|
self.logger.info(
|
|
f"Object creation handled: {self.app_name}[{self.test_stage.name}]: {str(data)}",
|
|
)
|
|
obj_tag = self.validate_object_creation_tag(
|
|
self.get_item(data, OBJ_TAG).decode("ascii")
|
|
)
|
|
|
|
last_add_object: MbObject = self.add_object(
|
|
obj_tag,
|
|
self.get_item(data, OBJ_ID),
|
|
self.get_item(data, TRANSACTION_TIMESTAMP),
|
|
)
|
|
self.logger.info("New added object: %s", last_add_object)
|
|
|
|
def handle_destroy(data: Optional[Any]) -> None:
|
|
"""Handle destroy"""
|
|
self.test_stage = Stages.STACK_DESTROY
|
|
self.logger.info(
|
|
f"Handle: {self.app_name}[{self.test_stage.name}]: {str(data)}"
|
|
)
|
|
self.send_message_destroy_other_dut_instances()
|
|
self.test_finish = True
|
|
|
|
while not self.test_finish:
|
|
try:
|
|
self.expect_any(
|
|
(dictionary[Stages.STACK_IPV4], handle_get_ip4),
|
|
(dictionary[Stages.STACK_IPV6], handle_get_ip6),
|
|
(dictionary[Stages.STACK_INIT], handle_init),
|
|
(dictionary[Stages.STACK_CONNECT], handle_connect),
|
|
(dictionary[Stages.STACK_START], handle_test_start),
|
|
(dictionary[Stages.STACK_PAR_OK], handle_par_ok),
|
|
(dictionary[Stages.STACK_PAR_FAIL], handle_par_fail),
|
|
(dictionary[Stages.STACK_OBJECT_CREATE], handle_obj_create),
|
|
(dictionary[Stages.STACK_DESTROY], handle_destroy),
|
|
(dictionary[Stages.STACK_BAD_CONNECTION], handle_bad_connection),
|
|
(
|
|
dictionary[Stages.STACK_CID_RESPONSE_TIME],
|
|
handle_cid_response_time,
|
|
),
|
|
timeout=timeout_value,
|
|
)
|
|
except pexpect.TIMEOUT:
|
|
self.logger.info(
|
|
f"{self.app_name}, expect timeout on stage {self.test_stage.name} ({timeout_value} seconds)"
|
|
)
|
|
self.send_message_destroy_other_dut_instances()
|
|
self.send_message_destroy_dut()
|
|
self.test_finish = True
|
|
|
|
def dut_check_errors(self) -> None:
|
|
"""Verify allowed percentage of errors for the dut"""
|
|
allowed_ok_percentage: float = 0
|
|
if self.param_ok_count or self.param_fail_count:
|
|
allowed_ok_percentage = (
|
|
self.param_ok_count
|
|
/ (self.param_ok_count + self.param_fail_count)
|
|
* 100
|
|
)
|
|
if allowed_ok_percentage > (100 - ALLOWED_PERCENT_OF_FAILS):
|
|
self.logger.info(
|
|
"%s: ok_count: %d, fail count: %d",
|
|
self.app_name,
|
|
self.param_ok_count,
|
|
self.param_fail_count,
|
|
)
|
|
else:
|
|
self.logger.error(
|
|
"%s: ok_count: %d, number of failed readings: %d exceeds: %d percent",
|
|
self.app_name,
|
|
self.param_ok_count,
|
|
self.param_fail_count,
|
|
ALLOWED_PERCENT_OF_FAILS,
|
|
)
|
|
raise RuntimeError from None
|
|
|
|
|
|
############
|
|
# Fixtures #
|
|
############
|
|
|
|
|
|
@pytest.fixture
|
|
def case_tester(dut: IdfDut, **kwargs) -> Generator[CaseTester, Any, None]: # type: ignore
|
|
yield CaseTester(dut, **kwargs)
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def session_tempdir() -> str:
|
|
_tmpdir: str = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"pytest_embedded_log",
|
|
datetime.now().strftime("%Y-%m-%d_%H-%M-%S"),
|
|
)
|
|
os.makedirs(_tmpdir, exist_ok=True)
|
|
return _tmpdir
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
@multi_dut_fixture
|
|
def junit_properties(
|
|
test_case_name: str, record_xml_attribute: Callable[[str, object], None]
|
|
) -> None:
|
|
"""
|
|
This fixture is autoused and will modify the junit report test case name to <target>.<config>.<case_name>
|
|
"""
|
|
record_xml_attribute("name", test_case_name)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def monkeypatch_module(request: FixtureRequest) -> MonkeyPatch:
|
|
mp = MonkeyPatch()
|
|
request.addfinalizer(mp.undo)
|
|
return mp
|
|
|
|
|
|
@pytest.fixture(scope="module", autouse=True)
|
|
def replace_dut_class(monkeypatch_module: MonkeyPatch) -> None:
|
|
monkeypatch_module.setattr("pytest_embedded_idf.IdfDut", ModbusTestDut)
|
|
|
|
|
|
@pytest.fixture
|
|
@multi_dut_argument
|
|
def config(request: FixtureRequest) -> str:
|
|
return getattr(request, "param", None) or DEFAULT_SDKCONFIG
|
|
|
|
|
|
@pytest.fixture
|
|
@multi_dut_fixture
|
|
def build_dir(
|
|
app_path: str, target: Optional[str], config: Optional[str]
|
|
) -> Optional[str]:
|
|
"""
|
|
Check local build dir with the following priority:
|
|
|
|
1. build_<target>_<config>
|
|
2. build_<target>
|
|
3. build_<config>
|
|
4. build
|
|
|
|
Args:
|
|
app_path: app path
|
|
target: target
|
|
config: config
|
|
|
|
Returns:
|
|
valid build directory
|
|
"""
|
|
check_dirs = []
|
|
|
|
if target is not None and config is not None:
|
|
check_dirs.append(f"build_{target}_{config}")
|
|
if target is not None:
|
|
check_dirs.append(f"build_{target}")
|
|
if config is not None:
|
|
check_dirs.append(f"build_{config}")
|
|
check_dirs.append("build")
|
|
|
|
binary_path = ""
|
|
|
|
for check_dir in check_dirs:
|
|
binary_path = os.path.join(app_path, check_dir)
|
|
if os.path.isdir(binary_path):
|
|
logging.info(f"find valid binary path: {binary_path}")
|
|
return check_dir
|
|
|
|
logging.warning(
|
|
"checking binary path: %s... missing... try another place", binary_path
|
|
)
|
|
|
|
if config is not None and "dummy" in config:
|
|
logging.warning(
|
|
"no build dir valid for application: %s, config: %s. Skip test.",
|
|
binary_path,
|
|
config,
|
|
)
|
|
return None
|
|
|
|
recommend_place = check_dirs[0]
|
|
logging.error(
|
|
f'no build dir valid. Please build the binary via "idf.py -B {recommend_place} build" and run pytest again'
|
|
)
|
|
|
|
sys.exit(1)
|