gRPC Profile

背景:
硬件测试平台需要把 JSON 格式的 TIB2/TIB3 配置通过 gRPC 下发到服务端。旧脚本只能识别 TIB2,且 CLI 参数写死,用户大喊「TIB3 还是没有!」——于是有了这次重构。


1. 需求一句话

  1. CLI 从 3 个参数减到 2 个:profile 路径 + 服务器地址(去掉 hw_target,自动加载全部配置)。
  2. 一份 JSON 里同时支持 TIB2/TIB3,空配置不再发空请求
  3. 所有枚举(波特率、电压、启动模式)必须自动映射到 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 倍。
  • 注释 JSONre.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"),后续所有 OneProfilename 字段都统一使用这个值。

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_positionsessionprofiles 列表。
  • 会话信息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_mapsrepeated IOMapping 字段,通过 add() 添加新元素。
  • 只有 Statetrue 时才显式设置 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.datarepeated 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))
  • voltagedouble 类型,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_settingsstate 同样遵循“只写 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 全自动翻译器”。它通过以下步骤保证了可靠性和灵活性:

  1. 读取与预处理:清理 JSON 注释,安全解析。
  2. 导航与遍历:定位到硬件目标层级,逐一处理。
  3. 动态枚举映射:借助 _get_proto_enum_string,将任意格式的 JSON 值转换为正确的 protobuf 枚举。
  4. 精准填充:对 repeated 字段使用 .add(),对可选字段采用 get() 默认值,对布尔值仅当 true 时才设置。
  5. 完备的错误处理:从文件读取到 gRPC 调用,每一层都有异常捕获和详细日志。
  6. 符合预期的输出:最终发送的请求结构与 protobuf 定义完全一致,且日志输出为清晰的 JSON 格式,便于验证。

理解这段代码,不仅能掌握 gRPC 客户端的开发模式,还能学习到如何将非结构化的配置文件安全地转换为强类型的 protobuf 消息,是工业级自动化测试系统的典范实现。

现在,TIB3 真的有了。

posted @ 2026-03-05 15:50  mo686  阅读(0)  评论(0)    收藏  举报