gRPC Profile
背景:
硬件测试平台需要把 JSON 格式的 TIB2/TIB3 配置通过 gRPC 下发到服务端。旧脚本只能识别 TIB2,且 CLI 参数写死,用户大喊「TIB3 还是没有!」——于是有了这次重构。
1. 需求一句话
- CLI 从 3 个参数减到 2 个:profile 路径 + 服务器地址(去掉 hw_target,自动加载全部配置)。
- 一份 JSON 里同时支持 TIB2/TIB3,空配置不再发空请求。
- 所有枚举(波特率、电压、启动模式)必须自动映射到 Protobuf 定义,不能硬编码。
2. 文件地图
| 文件 | 作用 |
|---|---|
profile_client.py |
核心逻辑:读 JSON → 构造 SetProfileRequest → 发 gRPC |
load_profile.py |
CLI 入口,调 profile_client |
proto_python/*_pb2.py |
protoc 生成的消息/枚举定义,只读 |
Raptor2.*.json |
真实嵌套配置(含注释) |
test |
期望的 gRPC 文本格式,用于 diff 验证 |
3. JSON → Protobuf 的「万能翻译器」
核心函数 _get_proto_enum_string 做了 4 层回退:
| 回退层级 | 举例 | 输出 |
|---|---|---|
| ① 特殊字典 | "115200" → BAUD_115200 |
UART_BAUDRATE_BAUD_115200 |
| ② 全大/小写 | "NONE" → NONE |
UART_PARITY_NONE |
| ③ 驼峰转蛇形 | "EnterDutBootModeInternal" → ENTER_DUT_BOOT_MODE_INTERNAL |
BOOT_MODE_SELECTION_ENTER_DUT_BOOT_MODE_INTERNAL |
| ④ 后缀模糊 | "TX" 命中 UART_PARITY_TX |
直接返回 |
代码片段(已折叠重复):
def _get_proto_enum_string(self, json_value, enum_type_name, enum_keys):
special_maps = {
"UartBaudrate": {"115200": "BAUD_115200", ...},
"IOVoltageLevel": {"V3P3": "V_3P3"},
...
}
...
# 1. 特殊映射
if enum_type_name in special_maps and json_value in special_maps[enum_type_name]:
snake_json_value = special_maps[enum_type_name][json_value]
else:
# 2. 通用驼峰→蛇形
snake_json_value = self._pascal_to_snake(json_value)
# 3. 类型前缀特殊处理:UartDataBits → UART_DATABITS
if enum_type_name.startswith("Uart"):
snake_enum_type = "UART_" + enum_type_name[4:].upper()
else:
snake_enum_type = self._pascal_to_snake(enum_type_name)
full_name = f"{snake_enum_type}_{snake_json_value}"
if full_name in enum_keys: # 4. 第一次命中
return full_name
if snake_json_value in enum_keys: # 5. 无前缀再试
return snake_json_value
for k in enum_keys: # 6. 后缀模糊
if k.endswith(f"_{snake_json_value}"):
return k
return f"{snake_enum_type}_UNSPECIFIED"
4. 嵌套 JSON 导航
真实文件结构:
{
"TestInterfaceConfiguration": {
"AIR1672": { // ← profile_group
"TIB2": { ... }, // ← hw_target
"TIB3": { ... }
}
}
}
代码用 next(iter(...)) 一把拿到 AIR1672,再 for hw_target, profile_details in profile_group.items() 遍历,再也不用手动指定 hw_target。
5. Protobuf 重复字段的正确姿势
| 错误写法 | 正确写法 |
|---|---|
one_profile.boot_modes.add() |
one_profile.boot_mode_config.boot_mode_configs.add() |
uart_config = one_profile.uart_configuration |
uart_config = one_profile.uart_configuration.data.add() |
踩坑:一开始把
boot_mode_config当成普通字段,结果AttributeError: boot_modes;用dir()现场侦查才发现boot_mode_config本身就是BootModeConfigurations(repeated)。
6. 最终 CLI 体验
python load_profile.py \
--profile_path Raptor2.TestInterface.AIR1672.Configuration.json \
--server localhost:50051
日志:
INFO Building profile for HW target 'TIB2'
INFO Building profile for HW target 'TIB3'
INFO Successfully set profile.
服务端收到完整请求,与 test 文件 diff 零差异。
7. 小结
- 枚举映射用 4 层回退,再也不怕 JSON 写法不统一。
- 嵌套结构用
next(iter())自动导航,新增硬件目标零代码改动。 - repeated 字段记得
.add(),现场dir()比盲猜快 10 倍。 - 注释 JSON先
re.sub(r"//.*", "", ...)再json.loads,优雅兼容带注释的配置。
在 profile_client.py 中,load_and_set_profile 是整个客户端的核心 —— 它将一个包含多个硬件目标配置的 JSON 文件,转化为符合 protobuf 定义的 SetProfileRequest,并通过 gRPC 发送给服务端。本文将逐行拆解这个方法,深入理解其设计思想和技术细节。
前置辅助函数
在深入主方法之前,有必要了解两个关键的辅助函数:
_pascal_to_snake:将驼峰命名(如"EnterDutBootModeInternal")转换为全大写下划线命名(如"ENTER_DUT_BOOT_MODE_INTERNAL"),用于构造枚举名称的后半部分。_get_proto_enum_string:将 JSON 中的字符串值(如"115200"、"V3P3"、"Input")映射为 protobuf 枚举的完整字符串名称(如"UART_BAUDRATE_BAUD_115200"、"IO_VOLTAGE_LEVEL_V_3P3"、"IO_DIRECTION_INPUT")。它通过内置的特殊映射表和通用驼峰转换,确保无论 JSON 中是什么写法,最终都能找到正确的枚举值。
这两个函数是整个转换过程的基石,保证了代码的灵活性和健壮性。
load_and_set_profile
1. 函数签名与文档
def load_and_set_profile(self, file_path, dut_position=1):
"""Loads all hardware profiles from a JSON file and sends them to the server."""
- 参数:
file_path:JSON 配置文件的路径(可以是绝对或相对路径)。dut_position:机台位置编号,默认为 1,用于多 DUT 场景。
- 功能:一次性读取文件中所有硬件目标的配置,打包成一个
SetProfileRequest并发送。
2. 连接状态检查
if not self.mmi_stub or not self.config_stub:
self.logger.error("Not connected. Call connect() first.")
return
- 防御式编程:如果 gRPC 存根(stub)不存在,说明尚未建立连接,直接报错退出,避免后续空指针异常。
3. 读取文件并去除注释
self.logger.info(f"Loading all profiles from {file_path}")
try:
with open(file_path, 'r') as f:
content = re.sub(r"//.*", "", f.read())
full_config = json.loads(content)
except (FileNotFoundError, json.JSONDecodeError) as e:
self.logger.error(f"Failed to read or parse profile file: {e}")
return
re.sub(r"//.*", "", ...):使用正则表达式删除 JSON 中的单行注释(以//开头的内容)。标准库json不允许注释,所以必须先清理。- 异常处理:分别捕获文件不存在和 JSON 解析错误,并记录详细日志,便于排查。
4. 定位 JSON 嵌套结构
test_interface_config = full_config.get('TestInterfaceConfiguration')
if not test_interface_config:
self.logger.error("No 'TestInterfaceConfiguration' found in the JSON file.")
return
profile_group = next(iter(test_interface_config.values()), None)
if not profile_group:
self.logger.error("No profile group (e.g., 'AIR1672') found inside 'TestInterfaceConfiguration'.")
return
profile_base_name = next(iter(test_interface_config.keys()), "UnknownProfile")
- JSON 的典型结构为:
{ "TestInterfaceConfiguration": { "AIR1672": { "TIB2": { ... }, "TCPE": { ... }, "TIB3": { ... } } } } test_interface_config指向{"AIR1672": {...}}这一层。profile_group取第一个值(即AIR1672对象),它包含了所有硬件目标的配置字典。profile_base_name取第一个键(即"AIR1672"),后续所有OneProfile的name字段都统一使用这个值。
5. 构建请求外壳并填充会话
profile_request = pb2.SetProfileRequest(dut_position=dut_position)
now = datetime.datetime.utcnow()
timestamp = Timestamp()
timestamp.FromDatetime(now)
profile_request.session.device_id = "MMI"
profile_request.session.start_time.CopyFrom(timestamp)
SetProfileRequest是 gRPC 请求的根消息,包含dut_position、session和profiles列表。- 会话信息:
device_id固定为"MMI",start_time使用当前 UTC 时间,通过CopyFrom赋值(protobuf 时间戳消息的固定用法)。
6. 遍历每个硬件目标
for hw_target, profile_details in profile_group.items():
if not isinstance(profile_details, dict):
self.logger.warning(f"Skipping item '{hw_target}' as it's not a valid profile object.")
continue
profile_group.items()遍历"TIB2"、"TCPE"等键及其对应的配置字典。- 如果某个值不是字典(例如格式错误),则跳过并记录警告。
7. 创建并填充 OneProfile
one_profile = profile_request.profiles.add()
one_profile.name = profile_base_name
one_profile.hw_target = hw_target
profiles.add()在 repeated 字段中新增一个OneProfile消息,返回该消息对象。- 设置通用字段:
name固定为"AIR1672",hw_target为当前硬件目标名称(如"TIB2")。
8. 处理 IO 别名映射
if 'IOConfigurations' in profile_details:
for config in profile_details['IOConfigurations']:
io_map = one_profile.io_map.io_maps.add()
io_map.alias = config.get('Alias', '')
io_map.actual_name = config.get('ActualName', '')
if config.get('State', False):
io_map.state = True
io_maps是repeated IOMapping字段,通过add()添加新元素。- 只有
State为true时才显式设置state字段;为false或缺失时,protobuf 默认值为false,且文本格式不会输出该字段,保持请求简洁。
9. 其他映射部分(UART、电压轨、风扇、触发器、外部报警)
这些部分的处理模式与 IO 映射完全一致,只是字段名不同:
if 'UartConfigurations' in profile_details:
for config in profile_details['UartConfigurations']:
uart_map = one_profile.uart_map.uart_maps.add()
uart_map.alias = config.get('Alias', '')
uart_map.actual_name = config.get('ActualName', '')
if 'VoltageRailConfigurations' in profile_details:
... # 类似
if 'FanConfigurations' in profile_details:
... # 类似
if 'TriggerConfigurations' in profile_details:
... # 新增,映射到 trigger_map.trigger_maps
if 'ExternalAlarmConfigurations' in profile_details:
... # 新增,映射到 external_alarm_map.external_alarm_maps
10. IO 初始配置
if 'IOConfigurationInitialSetting' in profile_details:
for config in profile_details['IOConfigurationInitialSetting']:
io_config = one_profile.io_configuration.data.add()
io_config.name = config.get('Name', '')
# IoDirection
dir_str = config.get('IoDirection')
if dir_str:
io_config.io_direction = common_enums_pb2.IODirection.Value(
self._get_proto_enum_string(dir_str, "IoDirection", common_enums_pb2.IODirection.keys())
)
# IoVoltageLevel
volt_str = config.get('IoVoltageLevel')
if volt_str:
io_config.io_voltage_level = common_enums_pb2.IOVoltageLevel.Value(
self._get_proto_enum_string(volt_str, "IOVoltageLevel", common_enums_pb2.IOVoltageLevel.keys())
)
io_configuration.data是repeated IOConfiguration字段。- 枚举字段通过
_get_proto_enum_string转换为字符串,再用Value()获取对应的整数值。 - 如果 JSON 中缺少某个字段,则不设置,protobuf 会使用默认值(通常为
UNSPECIFIED)。
11. UART 初始配置
if 'UartConfigurationInitialSetting' in profile_details:
for config in profile_details['UartConfigurationInitialSetting']:
uart_config = one_profile.uart_configuration.data.add()
uart_config.name = config.get('Name', '')
uart_config.port_number = config.get('PortNumber', 0)
# Baudrate
baud_str = config.get('Baudrate')
if baud_str:
uart_config.baudrate = common_enums_pb2.UartBaudrate.Value(
self._get_proto_enum_string(baud_str, "UartBaudrate", common_enums_pb2.UartBaudrate.keys())
)
# Parity, Databits, Stopbits, HardwareInterface, VoltageLevel 类似
- 每个 UART 配置项都包含多个枚举字段(波特率、奇偶校验、数据位等),全部通过
_get_proto_enum_string统一处理。 port_number是整数,直接从 JSON 获取。voltage_level复用IOVoltageLevel枚举,体现了 protobuf 的复用设计。
12. 电压轨初始配置
if 'VoltageRailConfigurationInitialSetting' in profile_details:
for config in profile_details['VoltageRailConfigurationInitialSetting']:
vr_config = one_profile.voltage_rail_configuration.data.add()
vr_config.name = config.get('Name', '')
vr_config.voltage = float(config.get('Voltage', 0.0))
voltage是double类型,JSON 中可能是字符串,需要显式转换为float。
13. 启动模式配置
if 'BootModeConfigurations' in profile_details:
for config in profile_details['BootModeConfigurations']:
boot_mode = one_profile.boot_mode_config.boot_mode_configs.add()
mode_str = config.get('Mode')
if mode_str:
boot_mode.mode = common_enums_pb2.BootModeSelection.Value(
self._get_proto_enum_string(mode_str, "BootModeSelection", common_enums_pb2.BootModeSelection.keys())
)
if 'IOSettings' in config:
for io_setting in config['IOSettings']:
io_setting_msg = boot_mode.io_settings.add()
io_setting_msg.io_name = io_setting.get('IoName', '')
if io_setting.get('State', False):
io_setting_msg.state = True
- 每个启动模式可以包含多个 IO 设置,形成两层嵌套。
io_settings的state同样遵循“只写 true”的原则。
14. 软件区域配置
if 'SoftwareAreaConfigurations' in profile_details:
for config in profile_details['SoftwareAreaConfigurations']:
sw_area = one_profile.software_repos.software_area_configurations.add()
sw_type_str = config.get('SwType')
if sw_type_str:
sw_area.sw_type = common_enums_pb2.SoftwareType.Value(
self._get_proto_enum_string(sw_type_str, "SoftwareType", common_enums_pb2.SoftwareType.keys())
)
# 地址为十六进制字符串,转换为整数
sw_area.start_address = int(config.get('StartAddress', '0'), 16)
sw_area.area_size = int(config.get('AreaSize', '0'), 16)
sw_area.erase_start_address = int(config.get('EraseStartAddress', '0'), 16)
sw_area.erase_area_size = int(config.get('EraseAreaSize', '0'), 16)
if 'File' in config:
file_info = config['File']
sw_area.file.name = file_info.get('Name', '')
sw_area.file.description = file_info.get('Description', '')
sw_area.file.version = file_info.get('Version', '')
sw_area.file.path = file_info.get('Path', '')
- 软件区域涉及十六进制地址,使用
int(..., 16)转换为整数,符合 protobuf 的uint32字段。 - 可选的
File子消息同样通过嵌套填充。
15. 空 profile 保护
if not profile_request.profiles:
self.logger.error("Failed to build any profiles from the file.")
return
- 如果循环结束后一个有效的
OneProfile都没有添加,则报错返回,避免发送空请求。
16. 发送请求并处理响应
self.logger.info("Sending SetProfile request...")
self.logger.debug(f"Request payload: {profile_request}")
try:
response = self.config_stub.SetProfile(profile_request)
self.logger.info(f"Request content: {json_format.MessageToJson(profile_request)}")
if response.code == 0:
self.logger.info("Successfully set profile.")
self.logger.info(f"Server response: {response}")
else:
self.logger.error(f"Failed to set profile. Response: {response}")
except grpc.RpcError as e:
self.logger.error(f"An RPC error occurred: {e.details()} (code: {e.code().name})")
config_stub.SetProfile是同步 gRPC 调用,会阻塞直到收到响应或超时。- 关键日志:使用
json_format.MessageToJson将整个请求转换为 JSON 字符串,便于调试和验证。这与用户要求的日志格式完全一致(驼峰字段名、枚举值字符串化)。 - 响应中的
code为 0 表示成功,非零表示业务层错误。 - 捕获
grpc.RpcError处理网络层异常(如连接失败、超时等)。
总结
load_and_set_profile 方法是一个功能强大的“JSON→gRPC 全自动翻译器”。它通过以下步骤保证了可靠性和灵活性:
- 读取与预处理:清理 JSON 注释,安全解析。
- 导航与遍历:定位到硬件目标层级,逐一处理。
- 动态枚举映射:借助
_get_proto_enum_string,将任意格式的 JSON 值转换为正确的 protobuf 枚举。 - 精准填充:对 repeated 字段使用
.add(),对可选字段采用get()默认值,对布尔值仅当true时才设置。 - 完备的错误处理:从文件读取到 gRPC 调用,每一层都有异常捕获和详细日志。
- 符合预期的输出:最终发送的请求结构与 protobuf 定义完全一致,且日志输出为清晰的 JSON 格式,便于验证。
理解这段代码,不仅能掌握 gRPC 客户端的开发模式,还能学习到如何将非结构化的配置文件安全地转换为强类型的 protobuf 消息,是工业级自动化测试系统的典范实现。
现在,TIB3 真的有了。

浙公网安备 33010602011771号