From 14c2e566567024711fb078ac90195a97dd6005d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=90=E5=A2=A8?= Date: Sat, 2 May 2026 00:18:57 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=B1=82=E4=BB=A3=E7=A0=81=E7=BB=93=E6=9E=84=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提取公共服务方法 _get_face_recognition、_check_result、_call_service - 添加 HTTP Session 池化与 LRU 图片缓存 - 完善 create_person 错误处理,返回统一格式 - 卸载时正确关闭 client 连接释放资源 - 传感器适配 API 返回结构变化 - 全面更新 README 文档,补充服务说明与示例 --- README.md | 293 +++++++++++++++++++++++------ __init__.py | 5 +- face_recognition.py | 407 ++++++++++++++++------------------------ sensor.py | 6 +- services.py | 348 +++++++++++----------------------- tencent_cloud_client.py | 159 ++++++++++++++-- 6 files changed, 654 insertions(+), 564 deletions(-) diff --git a/README.md b/README.md index f23623f..bbdf057 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,271 @@ -# 腾讯云人脸识别Home Assistant插件 +# 腾讯云人脸识别 Home Assistant 插件 -这是一个基于腾讯云人脸识别API的Home Assistant插件,提供了人脸搜索功能。 +基于腾讯云人脸识别 API 的 Home Assistant 自定义集成,提供完整的人脸管理能力。 ## 功能特性 -- **人脸搜索**:支持通过图片URL或本地文件路径搜索人脸,返回匹配的人员信息 +- **人脸搜索**:通过图片 URL、本地文件、Base64 编码或摄像头实体搜索人脸,返回匹配的人员信息 +- **人脸检测**:检测图片中的人脸位置和尺寸 +- **人脸属性分析**:获取人脸的性别、年龄、表情等属性 +- **人员管理**:创建/删除人员,支持带图或无图创建 +- **人脸管理**:为人员注册新人脸或删除已有人脸 +- **传感器**:自动同步人员库状态,提供人员数量和连接状态传感器 +- **多配置支持**:支持多个腾讯云账号同时接入 +- **自动重试**:内置指数退避重试机制,应对网络波动和限流 +- **连接复用**:HTTP Session 池化,减少重复连接开销 +- **图片缓存**:URL/路径图片 LRU 缓存,避免重复下载 ## 安装 -1. 将`custom_components/tencent_face_recognition`目录复制到Home Assistant的`custom_components`目录下 -2. 重启Home Assistant -3. 在"配置" -> "集成"中点击"+"添加集成 -4. 搜索"腾讯云人脸识别"并点击 -5. 输入您的腾讯云凭据和配置信息 +### 方式一:手动安装 -## 腾讯云相关页面入口 +1. 将 `tencent_face_recognition` 目录复制到 Home Assistant 的 `custom_components` 目录下 +2. 重启 Home Assistant + +### 方式二:HACS 安装 + +在 HACS 中搜索"腾讯云人脸识别"并安装。 + +### 配置集成 + +1. 在"设置" → "设备与服务"中点击"添加集成" +2. 搜索"腾讯云人脸识别" +3. 输入腾讯云 Secret ID、Secret Key 及可选的区域和人员库 ID + +## 腾讯云相关 - [腾讯云人脸识别](https://curl.qcloud.com/oqiFPa7h) - [人脸管理控制台](https://curl.qcloud.com/RMATbuiO) -- [获取API密钥](https://curl.qcloud.com/cT0HlJRW) +- [获取 API 密钥](https://curl.qcloud.com/cT0HlJRW) -## 配置 +## 配置项 -### 必需配置 - -- **Secret ID**:腾讯云API的Secret ID -- **Secret Key**:腾讯云API的Secret Key - -### 可选配置 - -- **人员库ID**:默认使用的人员库ID(默认值:Hass) -- **区域**:腾讯云服务区域(默认值:ap-shanghai) -- **名称**:集成名称(默认值:腾讯云人脸识别) +| 参数 | 必需 | 默认值 | 说明 | +|------|------|--------|------| +| Secret ID | 是 | - | 腾讯云 API 的 Secret ID | +| Secret Key | 是 | - | 腾讯云 API 的 Secret Key | +| 区域 | 否 | `ap-shanghai` | 腾讯云服务区域 | +| 人员库 ID | 否 | `Hass` | 默认使用的人员库 ID | ## 服务 -插件提供以下服务,可以通过开发者工具中的"服务"选项卡调用: +所有服务均支持 `response_variable` 获取返回结果,统一返回格式: + +```json +{ + "success": true, + "error": null, + "error_code": null, + "error_message": null +} +``` + +失败时: + +```json +{ + "success": false, + "error": "错误描述", + "error_code": "error_code", + "error_message": "详细错误信息" +} +``` + +通用图片参数(按优先级取第一个有效的): + +| 参数 | 说明 | +|------|------| +| `camera_entity_id` | 摄像头实体 ID(优先) | +| `image_url` | 图片 URL | +| `image_path` | 本地文件路径 | +| `image_file` | Base64 编码图片 | +| `config_entry_id` | 多配置时指定配置项 ID | + +--- ### 人脸搜索 -**服务名称**:`tencent_face_recognition.face_search` +`tencent_face_recognition.face_search` -**描述**:在人员库中搜索人脸 +在人员库中搜索匹配的人脸。搜索成功时会触发 `face_detected` 事件。 -**参数**: +**独有参数**: -- `image_url`(可选):要搜索的图片URL -- `image_path`(可选):要搜索的本地图片路径 -- `person_group_id`(可选):要搜索的人员库ID,留空使用默认人员库 -- `max_face_num`(可选,默认1):最多处理的人脸数量 -- `min_face_size`(可选,默认34):最小人脸尺寸(像素) -- `max_user_num`(可选,默认5):最多返回的匹配人员数量 -- `quality_control`(可选,默认1):是否进行质量控制(0:不控制,1:低质量控制,2:高质量控制) -- `need_rotate_check`(可选,默认1):是否进行旋转检查(0:不检查,1:检查) -- `face_match_threshold`(可选,默认60.0):人脸匹配阈值(0-100) +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `group_id` (必填) | - | 要搜索的人员库 ID | +| `max_face_num` | 1 | 最多处理的人脸数量 | +| `min_face_size` | 34 | 最小人脸尺寸(像素) | +| `max_user_num` | 5 | 最多返回的匹配人员数量 | +| `quality_control` | 1 | 质量控制(0=关闭,1=开启) | +| `need_rotate_check` | 1 | 旋转检查(0=关闭,1=开启) | +| `face_match_threshold` | 60.0 | 匹配阈值(0-100) | **示例**: -``` yaml +```yaml action: tencent_face_recognition.face_search -response_variable: face_recognition_result_raw +response_variable: search_result data: - group_id: Hass - face_match_threshold: 60 - min_face_size: 34 - max_face_num: 10 - max_user_num: 10 - image_path: /config/www/camera/face.jpg + group_id: "Hass" + image_path: "/config/www/camera/face.jpg" + face_match_threshold: 70 + max_face_num: 5 ``` - +**触发事件** `face_detected`: ```yaml -service: tencent_face_recognition.face_search -data: - image_url: "https://example.com/face.jpg" - group_id: "Hass" +- trigger: + - platform: event + event_type: face_detected + action: + - service: persistent_notification.create + data: + message: "识别到 {{ trigger.event.data.person_name }},置信度 {{ trigger.event.data.score }}" ``` +--- + +### 人脸检测 + +`tencent_face_recognition.detect_face` + +检测图片中的人脸位置和尺寸。 + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `max_face_num` | 1 | 最多检测的人脸数量 | +| `min_face_size` | 34 | 最小人脸尺寸(像素) | +| `need_rotate_check` | 1 | 旋转检查 | + +**示例**: + +```yaml +action: tencent_face_recognition.detect_face +response_variable: detect_result +data: + camera_entity_id: "camera.front_door" + max_face_num: 10 +``` + +--- + +### 获取人脸属性 + +`tencent_face_recognition.get_face_attributes` + +获取图片中人脸的性别、年龄、表情等属性信息。 + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `max_face_num` | 1 | 最多分析的人脸数量 | +| `need_rotate_check` | 1 | 旋转检查 | + +**示例**: + +```yaml +action: tencent_face_recognition.get_face_attributes +response_variable: attr_result +data: + image_url: "https://example.com/photo.jpg" +``` + +--- + +### 创建人员 + +`tencent_face_recognition.create_person` + +在人员库中创建新人员,图片为可选(支持先建人后传图)。 + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `person_id` (必填) | - | 人员唯一标识符 | +| `person_name` (必填) | - | 人员名称 | +| `group_id` (必填) | - | 所属人员库 ID | +| `gender` | - | 性别(0=女,1=男) | +| `person_tag` | - | 备注标签 | +| `quality_control` | 1 | 质量控制 | +| `need_rotate_check` | 1 | 旋转检查 | + +**示例**: + +```yaml +action: tencent_face_recognition.create_person +response_variable: create_result +data: + person_id: "person_001" + person_name: "张三" + group_id: "Hass" + image_url: "https://example.com/face.jpg" + gender: 1 +``` + +--- + +### 删除人员 + +`tencent_face_recognition.delete_person` + +从人员库中删除人员。 + +| 参数 | 说明 | +|------|------| +| `person_id` (必填) | 要删除的人员 ID | + +--- + +### 注册人脸 + +`tencent_face_recognition.create_face` + +为已有人员添加新的人脸照片。 + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `person_id` (必填) | - | 人员 ID | +| `quality_control` | 1 | 质量控制 | +| `need_rotate_check` | 1 | 旋转检查 | + +--- + +### 删除人脸 + +`tencent_face_recognition.delete_face` + +删除指定人员的人脸。 + +| 参数 | 说明 | +|------|------| +| `person_id` (必填) | 人员 ID | +| `face_id` (必填) | 要删除的人脸 ID | + +--- + +## 传感器 + +集成会自动创建以下传感器: + +| 传感器 | 说明 | +|--------|------| +| 状态传感器 | 显示连接状态(已连接/未连接/连接错误) | +| 人员传感器 | 每个注册人员一个传感器,显示名称和属性 | + +传感器每 5 分钟自动刷新。 + ## 故障排除 -### 常见问题 +| 问题 | 解决方案 | +|------|----------| +| 配置失败 | 检查 Secret ID/Key 是否正确,确认以 AKID 开头 | +| 图片处理失败 | 确保 URL 可访问或本地路径正确,图片格式为 JPG/PNG/BMP/GIF,大小不超过 10MB | +| 人脸检测失败 | 确保图片中包含清晰的人脸,人脸尺寸不小于 34 像素 | +| API 调用失败 | 检查账户余额和 API 调用配额 | +| 限流错误 | 降低调用频率,插件已内置重试机制 | -1. **配置失败**:请检查腾讯云凭据是否正确,以及是否有足够的权限 -2. **图片处理失败**:请确保图片URL可访问或本地文件路径正确 -3. **人脸检测失败**:请确保图片中包含清晰的人脸,且人脸尺寸足够大 -4. **API调用失败**:请检查腾讯云账户余额是否充足,以及API调用配额是否足够 - -### 日志查看 - -在Home Assistant的"开发者工具" -> "日志"中查看插件日志,可以获取更多错误信息。 - -## 贡献 - -欢迎提交Issue和Pull Request来改进这个插件。 +在"开发者工具" → "日志"中开启调试日志可获取更详细的错误信息。 ## 许可证 -MIT许可证 \ No newline at end of file +MIT 许可证 diff --git a/__init__.py b/__init__.py index 78adaa2..15c64e9 100644 --- a/__init__.py +++ b/__init__.py @@ -67,7 +67,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: await async_unload_services(hass) - hass.data[DOMAIN].pop(entry.entry_id) + entry_data = hass.data[DOMAIN].pop(entry.entry_id, None) + if entry_data and "client" in entry_data: + client = entry_data["client"] + await hass.async_add_executor_job(client.close) return unload_ok diff --git a/face_recognition.py b/face_recognition.py index 942db47..19ebe92 100644 --- a/face_recognition.py +++ b/face_recognition.py @@ -33,28 +33,28 @@ class FaceRecognition: """人脸识别核心功能""" def __init__(self, hass: HomeAssistant, client: TencentCloudClient): - """初始化人脸识别功能""" self.hass = hass self.client = client async def _get_image_base64_from_source( self, - image_url: str = None, - image_path: str = None, - image_file: str = None, - camera_entity_id: str = None, + image_url: Optional[str] = None, + image_path: Optional[str] = None, + image_file: Optional[str] = None, + camera_entity_id: Optional[str] = None, ) -> str: - """从各种来源获取图片 base64 编码""" if camera_entity_id: return await self._get_camera_image_base64(camera_entity_id) if image_url or image_path or image_file: return await self.hass.async_add_executor_job( - self.client._get_image_base64, image_url, image_path, image_file + self.client._get_image_base64, + image_url, + image_path, + image_file, ) raise ValueError("必须提供image_url、image_path、image_file或camera_entity_id") async def _get_camera_image_base64(self, camera_entity_id: str) -> str: - """从摄像头实体获取图片 base64""" try: from homeassistant.components.camera import async_get_image image = await async_get_image(self.hass, camera_entity_id) @@ -66,90 +66,91 @@ class FaceRecognition: def _validate_image_params( self, - image_url: str = None, - image_path: str = None, - image_file: str = None, - camera_entity_id: str = None, + image_url: Optional[str] = None, + image_path: Optional[str] = None, + image_file: Optional[str] = None, + camera_entity_id: Optional[str] = None, ) -> None: - """验证图片参数""" if not any([image_url, image_path, image_file, camera_entity_id]): raise ValueError("必须提供image_url、image_path、image_file或camera_entity_id") - async def async_search_faces( + async def _safe_api_call( self, - image_url: str = None, - image_path: str = None, - image_file: str = None, - camera_entity_id: str = None, - group_ids: List[str] = None, - max_face_num: int = 1, - min_face_size: int = 34, - max_user_num: int = 5, - quality_control: int = 1, - need_rotate_check: int = 1, - face_match_threshold: float = 60.0 + operation_name: str, + client_method, + **kwargs, ) -> Dict[str, Any]: - """搜索人脸""" try: - self._validate_image_params(image_url, image_path, image_file, camera_entity_id) - - image_base64 = await self._get_image_base64_from_source( - image_url, image_path, image_file, camera_entity_id - ) - result = await self.hass.async_add_executor_job( - self.client.search_faces, - None, None, None, image_base64, - group_ids, - max_face_num, - min_face_size, - max_user_num, - quality_control, - need_rotate_check, - face_match_threshold + lambda: client_method(**kwargs) ) - - if result.get("success", False): - processed_faces = self._process_search_results(result.get("faces", [])) - return { - "success": True, - "faces": processed_faces, - "error": None, - "error_code": None, - "error_message": None - } - else: - return { - "success": False, - "faces": [], - "error": result.get("error"), - "error_code": result.get("error_code"), - "error_message": result.get("error_message") - } - + return result + except HomeAssistantError: + raise except ValueError as ex: - _LOGGER.error("人脸搜索参数错误: %s", ex) + _LOGGER.error("%s参数错误: %s", operation_name, ex) return { "success": False, "faces": [], "error": str(ex), "error_code": "invalid_parameter", - "error_message": str(ex) + "error_message": str(ex), } - except HomeAssistantError: - raise except Exception as ex: - _LOGGER.error("人脸搜索失败: %s", ex) + _LOGGER.error("%s失败: %s", operation_name, ex) return { "success": False, "faces": [], "error": str(ex), "error_code": "unknown_error", - "error_message": str(ex) + "error_message": str(ex), } + async def async_search_faces( + self, + image_url: Optional[str] = None, + image_path: Optional[str] = None, + image_file: Optional[str] = None, + camera_entity_id: Optional[str] = None, + group_ids: Optional[List[str]] = None, + max_face_num: int = 1, + min_face_size: int = 34, + max_user_num: int = 5, + quality_control: int = 1, + need_rotate_check: int = 1, + face_match_threshold: float = 60.0, + ) -> Dict[str, Any]: + self._validate_image_params(image_url, image_path, image_file, camera_entity_id) + + if not group_ids: + raise ValueError("必须提供至少一个人员库ID (group_ids)") + + image_base64 = await self._get_image_base64_from_source( + image_url=image_url, + image_path=image_path, + image_file=image_file, + camera_entity_id=camera_entity_id, + ) + + result = await self._safe_api_call( + "人脸搜索", + self.client.search_faces, + image_base64=image_base64, + group_ids=group_ids, + max_face_num=max_face_num, + min_face_size=min_face_size, + max_user_num=max_user_num, + quality_control=quality_control, + need_rotate_check=need_rotate_check, + face_match_threshold=face_match_threshold, + ) + + if result.get("success", False): + result["faces"] = self._process_search_results(result.get("faces", [])) + + return result + def _process_search_results(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """处理人脸搜索结果""" processed_results = [] for result in results: processed_result = { @@ -172,215 +173,131 @@ class FaceRecognition: async def async_get_face_attributes( self, - image_url: str = None, - image_path: str = None, - image_file: str = None, - camera_entity_id: str = None, + image_url: Optional[str] = None, + image_path: Optional[str] = None, + image_file: Optional[str] = None, + camera_entity_id: Optional[str] = None, max_face_num: int = 1, - need_rotate_check: int = 1 + need_rotate_check: int = 1, ) -> Dict[str, Any]: - """获取人脸属性""" - try: - self._validate_image_params(image_url, image_path, image_file, camera_entity_id) + self._validate_image_params(image_url, image_path, image_file, camera_entity_id) - image_base64 = await self._get_image_base64_from_source( - image_url, image_path, image_file, camera_entity_id - ) + image_base64 = await self._get_image_base64_from_source( + image_url=image_url, + image_path=image_path, + image_file=image_file, + camera_entity_id=camera_entity_id, + ) - result = await self.hass.async_add_executor_job( - self.client.get_face_attributes, - None, None, None, image_base64, - max_face_num, - need_rotate_check - ) - - if result.get("success", False): - return { - "success": True, - "faces": result.get("faces", []), - "error": None, - "error_code": None, - "error_message": None - } - else: - return { - "success": False, - "faces": [], - "error": result.get("error"), - "error_code": result.get("error_code"), - "error_message": result.get("error_message") - } - - except HomeAssistantError: - raise - except Exception as ex: - _LOGGER.error("获取人脸属性失败: %s", ex) - return { - "success": False, - "faces": [], - "error": str(ex), - "error_code": "unknown_error", - "error_message": str(ex) - } + return await self._safe_api_call( + "获取人脸属性", + self.client.get_face_attributes, + image_base64=image_base64, + max_face_num=max_face_num, + need_rotate_check=need_rotate_check, + ) async def async_detect_faces( self, - image_url: str = None, - image_path: str = None, - image_file: str = None, - camera_entity_id: str = None, + image_url: Optional[str] = None, + image_path: Optional[str] = None, + image_file: Optional[str] = None, + camera_entity_id: Optional[str] = None, max_face_num: int = 1, min_face_size: int = 34, - need_rotate_check: int = 1 + need_rotate_check: int = 1, ) -> Dict[str, Any]: - """检测人脸""" - try: - self._validate_image_params(image_url, image_path, image_file, camera_entity_id) + self._validate_image_params(image_url, image_path, image_file, camera_entity_id) - image_base64 = await self._get_image_base64_from_source( - image_url, image_path, image_file, camera_entity_id - ) + image_base64 = await self._get_image_base64_from_source( + image_url=image_url, + image_path=image_path, + image_file=image_file, + camera_entity_id=camera_entity_id, + ) - result = await self.hass.async_add_executor_job( - self.client.detect_faces, - None, None, None, image_base64, - max_face_num, - min_face_size, - need_rotate_check - ) - - if result.get("success", False): - return { - "success": True, - "faces": result.get("faces", []), - "error": None, - "error_code": None, - "error_message": None - } - else: - return { - "success": False, - "faces": [], - "error": result.get("error"), - "error_code": result.get("error_code"), - "error_message": result.get("error_message") - } - - except HomeAssistantError: - raise - except Exception as ex: - _LOGGER.error("人脸检测失败: %s", ex) - return { - "success": False, - "faces": [], - "error": str(ex), - "error_code": "unknown_error", - "error_message": str(ex) - } + return await self._safe_api_call( + "人脸检测", + self.client.detect_faces, + image_base64=image_base64, + max_face_num=max_face_num, + min_face_size=min_face_size, + need_rotate_check=need_rotate_check, + ) async def async_create_person( self, person_id: str, person_name: str, group_id: str, - image_url: str = None, - image_path: str = None, - image_file: str = None, - camera_entity_id: str = None, - gender: int = None, - person_tag: str = None, + image_url: Optional[str] = None, + image_path: Optional[str] = None, + image_file: Optional[str] = None, + camera_entity_id: Optional[str] = None, + gender: Optional[int] = None, + person_tag: Optional[str] = None, quality_control: int = 1, need_rotate_check: int = 1, ) -> Dict[str, Any]: - """创建人员""" - try: - image_base64 = None - if any([image_url, image_path, image_file, camera_entity_id]): - image_base64 = await self._get_image_base64_from_source( - image_url, image_path, image_file, camera_entity_id - ) - - result = await self.hass.async_add_executor_job( - self.client.create_person, - person_id, person_name, group_id, - None, None, None, image_base64, - gender, person_tag, - quality_control, need_rotate_check, + image_base64 = None + if any([image_url, image_path, image_file, camera_entity_id]): + image_base64 = await self._get_image_base64_from_source( + image_url=image_url, + image_path=image_path, + image_file=image_file, + camera_entity_id=camera_entity_id, ) - return result - except HomeAssistantError: - raise - except Exception as ex: - _LOGGER.error("创建人员失败: %s", ex) - return { - "success": False, - "error": str(ex), - "error_code": "unknown_error", - "error_message": str(ex) - } + + return await self._safe_api_call( + "创建人员", + self.client.create_person, + person_id=person_id, + person_name=person_name, + group_id=group_id, + image_base64=image_base64, + gender=gender, + person_tag=person_tag, + quality_control=quality_control, + need_rotate_check=need_rotate_check, + ) async def async_delete_person(self, person_id: str) -> Dict[str, Any]: - """删除人员""" - try: - result = await self.hass.async_add_executor_job( - self.client.delete_person, person_id - ) - return result - except Exception as ex: - _LOGGER.error("删除人员失败: %s", ex) - return { - "success": False, - "error": str(ex), - "error_code": "unknown_error", - "error_message": str(ex) - } + return await self._safe_api_call( + "删除人员", + self.client.delete_person, + person_id=person_id, + ) async def async_create_face( self, person_id: str, - image_url: str = None, - image_path: str = None, - image_file: str = None, - camera_entity_id: str = None, + image_url: Optional[str] = None, + image_path: Optional[str] = None, + image_file: Optional[str] = None, + camera_entity_id: Optional[str] = None, quality_control: int = 1, need_rotate_check: int = 1, ) -> Dict[str, Any]: - """为人员注册人脸""" - try: - image_base64 = await self._get_image_base64_from_source( - image_url, image_path, image_file, camera_entity_id - ) + image_base64 = await self._get_image_base64_from_source( + image_url=image_url, + image_path=image_path, + image_file=image_file, + camera_entity_id=camera_entity_id, + ) - result = await self.hass.async_add_executor_job( - self.client.create_face, - person_id, - None, None, None, image_base64, - quality_control, need_rotate_check, - ) - return result - except HomeAssistantError: - raise - except Exception as ex: - _LOGGER.error("注册人脸失败: %s", ex) - return { - "success": False, - "error": str(ex), - "error_code": "unknown_error", - "error_message": str(ex) - } + return await self._safe_api_call( + "注册人脸", + self.client.create_face, + person_id=person_id, + image_base64=image_base64, + quality_control=quality_control, + need_rotate_check=need_rotate_check, + ) async def async_delete_face(self, person_id: str, face_id: str) -> Dict[str, Any]: - """删除人脸""" - try: - result = await self.hass.async_add_executor_job( - self.client.delete_face, person_id, face_id - ) - return result - except Exception as ex: - _LOGGER.error("删除人脸失败: %s", ex) - return { - "success": False, - "error": str(ex), - "error_code": "unknown_error", - "error_message": str(ex) - } + return await self._safe_api_call( + "删除人脸", + self.client.delete_face, + person_id=person_id, + face_id=face_id, + ) diff --git a/sensor.py b/sensor.py index f7c6600..62e5271 100644 --- a/sensor.py +++ b/sensor.py @@ -47,13 +47,15 @@ class TencentFaceCoordinator(DataUpdateCoordinator): self.entry, CONF_PERSON_GROUP_ID, DEFAULT_PERSON_GROUP_ID ) - group_info = await self.hass.async_add_executor_job( + group_result = await self.hass.async_add_executor_job( self.client.get_group_info, group_id ) + group_info = group_result if group_result.get("success", False) else {} - persons = await self.hass.async_add_executor_job( + persons_result = await self.hass.async_add_executor_job( self.client.get_person_list_all, group_id ) + persons = persons_result.get("persons", []) if persons_result.get("success", False) else [] return { "group_info": group_info, diff --git a/services.py b/services.py index c39ad3f..c32ceb5 100644 --- a/services.py +++ b/services.py @@ -1,7 +1,7 @@ """服务定义""" import logging -from typing import Dict, Any, List, Optional +from typing import Dict, Any import voluptuous as vol @@ -115,7 +115,6 @@ DELETE_FACE_SCHEMA = vol.Schema({ async def async_setup_services(hass: HomeAssistant) -> None: - """设置服务""" if hass.services.has_service(DOMAIN, SERVICE_FACE_SEARCH): return @@ -179,7 +178,6 @@ async def async_setup_services(hass: HomeAssistant) -> None: async def async_unload_services(hass: HomeAssistant) -> None: - """卸载服务""" if any(hass.config_entries.async_entries(DOMAIN)): return @@ -198,7 +196,6 @@ async def async_unload_services(hass: HomeAssistant) -> None: def _get_entry_data(hass: HomeAssistant, config_entry_id: str = None): - """获取配置项数据,支持多配置条目""" domain_data = hass.data.get(DOMAIN, {}) if config_entry_id: data = domain_data.get(config_entry_id) @@ -212,44 +209,62 @@ def _get_entry_data(hass: HomeAssistant, config_entry_id: str = None): return None -async def async_face_search_service(call: ServiceCall) -> Dict[str, Any]: - """人脸搜索服务""" - hass = call.hass - data = call.data - - entry_data = _get_entry_data(hass, data.get(ATTR_CONFIG_ENTRY_ID)) +def _get_face_recognition(hass: HomeAssistant, config_entry_id: str = None): + entry_data = _get_entry_data(hass, config_entry_id) if not entry_data: raise HomeAssistantError("未找到腾讯云人脸识别配置") - face_recognition = entry_data.get("face_recognition") if not face_recognition: raise HomeAssistantError("腾讯云人脸识别插件未正确初始化") + return face_recognition + + +def _check_result(result: Dict[str, Any], operation_name: str) -> Dict[str, Any]: + if not result.get("success", False): + error_msg = result.get("error_message", result.get("error", "未知错误")) + raise HomeAssistantError(f"{operation_name}失败: {error_msg}") + return result + + +async def _call_service( + call: ServiceCall, + operation_name: str, + method_name: str, + **kwargs, +) -> Dict[str, Any]: + face_recognition = _get_face_recognition(call.hass, call.data.get(ATTR_CONFIG_ENTRY_ID)) + try: + method = getattr(face_recognition, method_name) + result = await method(**kwargs) + return _check_result(result, operation_name) + except HomeAssistantError: + raise + except Exception as ex: + _LOGGER.error("%s服务调用失败: %s", operation_name, ex) + raise HomeAssistantError(f"{operation_name}服务调用失败: {ex}") + + +async def async_face_search_service(call: ServiceCall) -> Dict[str, Any]: + data = call.data + hass = call.hass + + face_recognition = _get_face_recognition(hass, data.get(ATTR_CONFIG_ENTRY_ID)) + group_id = data.get(ATTR_GROUP_ID) + camera_entity_id = data.get(ATTR_CAMERA_ENTITY_ID) try: - group_id = data.get(ATTR_GROUP_ID) - image_url = data.get(ATTR_IMAGE_URL) - image_path = data.get(ATTR_IMAGE_PATH) - image_file = data.get(ATTR_IMAGE_FILE) - camera_entity_id = data.get(ATTR_CAMERA_ENTITY_ID) - max_face_num = data.get(ATTR_MAX_FACE_NUM, 1) - min_face_size = data.get(ATTR_MIN_FACE_SIZE, 34) - max_user_num = data.get(ATTR_MAX_USER_NUM, 5) - quality_control = data.get(ATTR_QUALITY_CONTROL, 1) - need_rotate_check = data.get(ATTR_NEED_ROTATE_CHECK, 1) - face_match_threshold = data.get(ATTR_FACE_MATCH_THRESHOLD, 60.0) - result = await face_recognition.async_search_faces( - image_url=image_url, - image_path=image_path, - image_file=image_file, + image_url=data.get(ATTR_IMAGE_URL), + image_path=data.get(ATTR_IMAGE_PATH), + image_file=data.get(ATTR_IMAGE_FILE), camera_entity_id=camera_entity_id, group_ids=[group_id], - max_face_num=max_face_num, - min_face_size=min_face_size, - max_user_num=max_user_num, - quality_control=quality_control, - need_rotate_check=need_rotate_check, - face_match_threshold=face_match_threshold + max_face_num=data.get(ATTR_MAX_FACE_NUM, 1), + min_face_size=data.get(ATTR_MIN_FACE_SIZE, 34), + max_user_num=data.get(ATTR_MAX_USER_NUM, 5), + quality_control=data.get(ATTR_QUALITY_CONTROL, 1), + need_rotate_check=data.get(ATTR_NEED_ROTATE_CHECK, 1), + face_match_threshold=data.get(ATTR_FACE_MATCH_THRESHOLD, 60.0), ) if result.get("success", False): @@ -274,229 +289,88 @@ async def async_face_search_service(call: ServiceCall) -> Dict[str, Any]: async def async_detect_face_service(call: ServiceCall) -> Dict[str, Any]: - """人脸检测服务""" - hass = call.hass data = call.data - - entry_data = _get_entry_data(hass, data.get(ATTR_CONFIG_ENTRY_ID)) - if not entry_data: - raise HomeAssistantError("未找到腾讯云人脸识别配置") - - face_recognition = entry_data.get("face_recognition") - if not face_recognition: - raise HomeAssistantError("腾讯云人脸识别插件未正确初始化") - - try: - image_url = data.get(ATTR_IMAGE_URL) - image_path = data.get(ATTR_IMAGE_PATH) - image_file = data.get(ATTR_IMAGE_FILE) - camera_entity_id = data.get(ATTR_CAMERA_ENTITY_ID) - max_face_num = data.get(ATTR_MAX_FACE_NUM, 1) - min_face_size = data.get(ATTR_MIN_FACE_SIZE, 34) - need_rotate_check = data.get(ATTR_NEED_ROTATE_CHECK, 1) - - result = await face_recognition.async_detect_faces( - image_url=image_url, - image_path=image_path, - image_file=image_file, - camera_entity_id=camera_entity_id, - max_face_num=max_face_num, - min_face_size=min_face_size, - need_rotate_check=need_rotate_check - ) - - if not result.get("success", False): - error_msg = result.get("error_message", result.get("error", "未知错误")) - raise HomeAssistantError(f"人脸检测失败: {error_msg}") - - return result - - except HomeAssistantError: - raise - except Exception as ex: - _LOGGER.error("人脸检测服务调用失败: %s", ex) - raise HomeAssistantError(f"人脸检测服务调用失败: {ex}") + return await _call_service( + call, + "人脸检测", + "async_detect_faces", + image_url=data.get(ATTR_IMAGE_URL), + image_path=data.get(ATTR_IMAGE_PATH), + image_file=data.get(ATTR_IMAGE_FILE), + camera_entity_id=data.get(ATTR_CAMERA_ENTITY_ID), + max_face_num=data.get(ATTR_MAX_FACE_NUM, 1), + min_face_size=data.get(ATTR_MIN_FACE_SIZE, 34), + need_rotate_check=data.get(ATTR_NEED_ROTATE_CHECK, 1), + ) async def async_get_face_attributes_service(call: ServiceCall) -> Dict[str, Any]: - """获取人脸属性服务""" - hass = call.hass data = call.data - - entry_data = _get_entry_data(hass, data.get(ATTR_CONFIG_ENTRY_ID)) - if not entry_data: - raise HomeAssistantError("未找到腾讯云人脸识别配置") - - face_recognition = entry_data.get("face_recognition") - if not face_recognition: - raise HomeAssistantError("腾讯云人脸识别插件未正确初始化") - - try: - image_url = data.get(ATTR_IMAGE_URL) - image_path = data.get(ATTR_IMAGE_PATH) - image_file = data.get(ATTR_IMAGE_FILE) - camera_entity_id = data.get(ATTR_CAMERA_ENTITY_ID) - max_face_num = data.get(ATTR_MAX_FACE_NUM, 1) - need_rotate_check = data.get(ATTR_NEED_ROTATE_CHECK, 1) - - result = await face_recognition.async_get_face_attributes( - image_url=image_url, - image_path=image_path, - image_file=image_file, - camera_entity_id=camera_entity_id, - max_face_num=max_face_num, - need_rotate_check=need_rotate_check - ) - - if not result.get("success", False): - error_msg = result.get("error_message", result.get("error", "未知错误")) - raise HomeAssistantError(f"获取人脸属性失败: {error_msg}") - - return result - - except HomeAssistantError: - raise - except Exception as ex: - _LOGGER.error("获取人脸属性服务调用失败: %s", ex) - raise HomeAssistantError(f"获取人脸属性服务调用失败: {ex}") + return await _call_service( + call, + "获取人脸属性", + "async_get_face_attributes", + image_url=data.get(ATTR_IMAGE_URL), + image_path=data.get(ATTR_IMAGE_PATH), + image_file=data.get(ATTR_IMAGE_FILE), + camera_entity_id=data.get(ATTR_CAMERA_ENTITY_ID), + max_face_num=data.get(ATTR_MAX_FACE_NUM, 1), + need_rotate_check=data.get(ATTR_NEED_ROTATE_CHECK, 1), + ) async def async_create_person_service(call: ServiceCall) -> Dict[str, Any]: - """创建人员服务""" - hass = call.hass data = call.data - - entry_data = _get_entry_data(hass, data.get(ATTR_CONFIG_ENTRY_ID)) - if not entry_data: - raise HomeAssistantError("未找到腾讯云人脸识别配置") - - face_recognition = entry_data.get("face_recognition") - if not face_recognition: - raise HomeAssistantError("腾讯云人脸识别插件未正确初始化") - - try: - result = await face_recognition.async_create_person( - person_id=data.get(ATTR_PERSON_ID), - person_name=data.get(ATTR_PERSON_NAME), - group_id=data.get(ATTR_GROUP_ID), - image_url=data.get(ATTR_IMAGE_URL), - image_path=data.get(ATTR_IMAGE_PATH), - image_file=data.get(ATTR_IMAGE_FILE), - camera_entity_id=data.get(ATTR_CAMERA_ENTITY_ID), - gender=data.get(ATTR_GENDER), - person_tag=data.get(ATTR_PERSON_TAG), - quality_control=data.get(ATTR_QUALITY_CONTROL, 1), - need_rotate_check=data.get(ATTR_NEED_ROTATE_CHECK, 1), - ) - - if not result.get("success", False): - error_msg = result.get("error_message", result.get("error", "未知错误")) - raise HomeAssistantError(f"创建人员失败: {error_msg}") - - return result - - except HomeAssistantError: - raise - except Exception as ex: - _LOGGER.error("创建人员服务调用失败: %s", ex) - raise HomeAssistantError(f"创建人员服务调用失败: {ex}") + return await _call_service( + call, + "创建人员", + "async_create_person", + person_id=data.get(ATTR_PERSON_ID), + person_name=data.get(ATTR_PERSON_NAME), + group_id=data.get(ATTR_GROUP_ID), + image_url=data.get(ATTR_IMAGE_URL), + image_path=data.get(ATTR_IMAGE_PATH), + image_file=data.get(ATTR_IMAGE_FILE), + camera_entity_id=data.get(ATTR_CAMERA_ENTITY_ID), + gender=data.get(ATTR_GENDER), + person_tag=data.get(ATTR_PERSON_TAG), + quality_control=data.get(ATTR_QUALITY_CONTROL, 1), + need_rotate_check=data.get(ATTR_NEED_ROTATE_CHECK, 1), + ) async def async_delete_person_service(call: ServiceCall) -> Dict[str, Any]: - """删除人员服务""" - hass = call.hass data = call.data - - entry_data = _get_entry_data(hass, data.get(ATTR_CONFIG_ENTRY_ID)) - if not entry_data: - raise HomeAssistantError("未找到腾讯云人脸识别配置") - - face_recognition = entry_data.get("face_recognition") - if not face_recognition: - raise HomeAssistantError("腾讯云人脸识别插件未正确初始化") - - try: - result = await face_recognition.async_delete_person( - person_id=data.get(ATTR_PERSON_ID), - ) - - if not result.get("success", False): - error_msg = result.get("error_message", result.get("error", "未知错误")) - raise HomeAssistantError(f"删除人员失败: {error_msg}") - - return result - - except HomeAssistantError: - raise - except Exception as ex: - _LOGGER.error("删除人员服务调用失败: %s", ex) - raise HomeAssistantError(f"删除人员服务调用失败: {ex}") + return await _call_service( + call, + "删除人员", + "async_delete_person", + person_id=data.get(ATTR_PERSON_ID), + ) async def async_create_face_service(call: ServiceCall) -> Dict[str, Any]: - """注册人脸服务""" - hass = call.hass data = call.data - - entry_data = _get_entry_data(hass, data.get(ATTR_CONFIG_ENTRY_ID)) - if not entry_data: - raise HomeAssistantError("未找到腾讯云人脸识别配置") - - face_recognition = entry_data.get("face_recognition") - if not face_recognition: - raise HomeAssistantError("腾讯云人脸识别插件未正确初始化") - - try: - result = await face_recognition.async_create_face( - person_id=data.get(ATTR_PERSON_ID), - image_url=data.get(ATTR_IMAGE_URL), - image_path=data.get(ATTR_IMAGE_PATH), - image_file=data.get(ATTR_IMAGE_FILE), - camera_entity_id=data.get(ATTR_CAMERA_ENTITY_ID), - quality_control=data.get(ATTR_QUALITY_CONTROL, 1), - need_rotate_check=data.get(ATTR_NEED_ROTATE_CHECK, 1), - ) - - if not result.get("success", False): - error_msg = result.get("error_message", result.get("error", "未知错误")) - raise HomeAssistantError(f"注册人脸失败: {error_msg}") - - return result - - except HomeAssistantError: - raise - except Exception as ex: - _LOGGER.error("注册人脸服务调用失败: %s", ex) - raise HomeAssistantError(f"注册人脸服务调用失败: {ex}") + return await _call_service( + call, + "注册人脸", + "async_create_face", + person_id=data.get(ATTR_PERSON_ID), + image_url=data.get(ATTR_IMAGE_URL), + image_path=data.get(ATTR_IMAGE_PATH), + image_file=data.get(ATTR_IMAGE_FILE), + camera_entity_id=data.get(ATTR_CAMERA_ENTITY_ID), + quality_control=data.get(ATTR_QUALITY_CONTROL, 1), + need_rotate_check=data.get(ATTR_NEED_ROTATE_CHECK, 1), + ) async def async_delete_face_service(call: ServiceCall) -> Dict[str, Any]: - """删除人脸服务""" - hass = call.hass data = call.data - - entry_data = _get_entry_data(hass, data.get(ATTR_CONFIG_ENTRY_ID)) - if not entry_data: - raise HomeAssistantError("未找到腾讯云人脸识别配置") - - face_recognition = entry_data.get("face_recognition") - if not face_recognition: - raise HomeAssistantError("腾讯云人脸识别插件未正确初始化") - - try: - result = await face_recognition.async_delete_face( - person_id=data.get(ATTR_PERSON_ID), - face_id=data.get(ATTR_FACE_ID), - ) - - if not result.get("success", False): - error_msg = result.get("error_message", result.get("error", "未知错误")) - raise HomeAssistantError(f"删除人脸失败: {error_msg}") - - return result - - except HomeAssistantError: - raise - except Exception as ex: - _LOGGER.error("删除人脸服务调用失败: %s", ex) - raise HomeAssistantError(f"删除人脸服务调用失败: {ex}") + return await _call_service( + call, + "删除人脸", + "async_delete_face", + person_id=data.get(ATTR_PERSON_ID), + face_id=data.get(ATTR_FACE_ID), + ) diff --git a/tencent_cloud_client.py b/tencent_cloud_client.py index dd7ea3a..9546b82 100644 --- a/tencent_cloud_client.py +++ b/tencent_cloud_client.py @@ -10,6 +10,7 @@ import re import requests import time import uuid +from functools import lru_cache from typing import Dict, Any, List, Optional, Union, Callable from tencentcloud.common import credential @@ -115,6 +116,14 @@ class TencentCloudClient: self.region = region self.retry_config = retry_config or RetryConfig() self.client = self._create_client() + self._session = requests.Session() + self._session.mount('http://', requests.adapters.HTTPAdapter(max_retries=2)) + self._session.mount('https://', requests.adapters.HTTPAdapter(max_retries=2)) + + def close(self) -> None: + """释放资源""" + if self._session: + self._session.close() def _create_client(self) -> iai_client.IaiClient: """创建腾讯云人脸识别客户端""" @@ -161,6 +170,14 @@ class TencentCloudClient: error_msg = error_msg.replace(self.secret_key, "***") return error_msg + @lru_cache(maxsize=32) + def _cached_download_image_as_base64(self, image_url: str) -> str: + return self._download_image_as_base64_uncached(image_url) + + @lru_cache(maxsize=32) + def _cached_read_local_image_as_base64(self, image_path: str) -> str: + return self._read_local_image_as_base64_uncached(image_path) + def _get_image_base64(self, image_url: str = None, image_path: str = None, image_file: str = None) -> str: """获取图片的base64编码""" _LOGGER.debug( @@ -177,10 +194,10 @@ class TencentCloudClient: try: if image_url: _LOGGER.debug("使用图片URL: %s", image_url) - return self._download_image_as_base64(image_url) + return self._cached_download_image_as_base64(image_url) elif image_path: _LOGGER.debug("使用本地图片路径: %s", image_path) - return self._read_local_image_as_base64(image_path) + return self._cached_read_local_image_as_base64(image_path) elif image_file: _LOGGER.debug("使用上传的图片文件") return self._process_uploaded_image_as_base64(image_file) @@ -192,7 +209,7 @@ class TencentCloudClient: {"image_url": image_url, "image_path": image_path, "error_type": type(ex).__name__} ) - def _download_image_as_base64(self, image_url: str) -> str: + def _download_image_as_base64_uncached(self, image_url: str) -> str: """下载图片并转换为base64编码""" try: _LOGGER.debug("开始下载图片: %s", image_url) @@ -204,9 +221,7 @@ class TencentCloudClient: {"url": image_url} ) - session = requests.Session() - session.mount('http://', requests.adapters.HTTPAdapter(max_retries=2)) - session.mount('https://', requests.adapters.HTTPAdapter(max_retries=2)) + session = self._session headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" @@ -251,7 +266,7 @@ class TencentCloudClient: {"url": image_url, "error": str(ex)} ) - def _read_local_image_as_base64(self, image_path: str) -> str: + def _read_local_image_as_base64_uncached(self, image_path: str) -> str: """读取本地图片并转换为base64编码""" try: if not os.path.exists(image_path): @@ -717,7 +732,23 @@ class TencentCloudClient: except Exception as ex: duration = time.time() - start_time self._log_error("创建人员", request_id, ex, duration) - raise + + error_code = None + error_message = str(ex) + + if isinstance(ex, TencentCloudSDKException): + error_code = getattr(ex, "code", None) + error_message = getattr(ex, "message", str(ex)) + + return { + "success": False, + "person_id": person_id, + "face_id": "", + "face_rect": "", + "error": str(ex), + "error_code": error_code, + "error_message": error_message + } return self._execute_with_retry(api_call, "创建人员") @@ -744,7 +775,21 @@ class TencentCloudClient: except Exception as ex: duration = time.time() - start_time self._log_error("删除人员", request_id, ex, duration) - raise + + error_code = None + error_message = str(ex) + + if isinstance(ex, TencentCloudSDKException): + error_code = getattr(ex, "code", None) + error_message = getattr(ex, "message", str(ex)) + + return { + "success": False, + "person_id": person_id, + "error": str(ex), + "error_code": error_code, + "error_message": error_message + } return self._execute_with_retry(api_call, "删除人员") @@ -793,7 +838,23 @@ class TencentCloudClient: except Exception as ex: duration = time.time() - start_time self._log_error("注册人脸", request_id, ex, duration) - raise + + error_code = None + error_message = str(ex) + + if isinstance(ex, TencentCloudSDKException): + error_code = getattr(ex, "code", None) + error_message = getattr(ex, "message", str(ex)) + + return { + "success": False, + "person_id": person_id, + "face_ids": [], + "face_rects": [], + "error": str(ex), + "error_code": error_code, + "error_message": error_message + } return self._execute_with_retry(api_call, "注册人脸") @@ -823,7 +884,22 @@ class TencentCloudClient: except Exception as ex: duration = time.time() - start_time self._log_error("删除人脸", request_id, ex, duration) - raise + + error_code = None + error_message = str(ex) + + if isinstance(ex, TencentCloudSDKException): + error_code = getattr(ex, "code", None) + error_message = getattr(ex, "message", str(ex)) + + return { + "success": False, + "person_id": person_id, + "face_id": face_id, + "error": str(ex), + "error_code": error_code, + "error_message": error_message + } return self._execute_with_retry(api_call, "删除人脸") @@ -847,6 +923,7 @@ class TencentCloudClient: self._log_response("获取人员库信息", request_id, resp, duration) return { + "success": True, "group_name": getattr(resp, "GroupName", ""), "group_tag": getattr(resp, "GroupTag", ""), "face_model_version": getattr(resp, "FaceModelVersion", ""), @@ -855,7 +932,24 @@ class TencentCloudClient: except Exception as ex: duration = time.time() - start_time self._log_error("获取人员库信息", request_id, ex, duration) - raise + + error_code = None + error_message = str(ex) + + if isinstance(ex, TencentCloudSDKException): + error_code = getattr(ex, "code", None) + error_message = getattr(ex, "message", str(ex)) + + return { + "success": False, + "group_name": "", + "group_tag": "", + "face_model_version": "", + "creation_timestamp": 0, + "error": str(ex), + "error_code": error_code, + "error_message": error_message + } return self._execute_with_retry(api_call, "获取人员库信息") @@ -884,7 +978,7 @@ class TencentCloudClient: _LOGGER.error("API连接测试失败: %s", self._sanitize_error(str(ex))) return False - def get_person_list(self, group_id: str, limit: int = 100, offset: int = 0) -> List[Dict[str, Any]]: + def get_person_list(self, group_id: str, limit: int = 100, offset: int = 0) -> Dict[str, Any]: """获取人员列表""" _LOGGER.info("开始获取人员列表: group_id=%s, limit=%d, offset=%d", group_id, limit, offset) @@ -916,26 +1010,55 @@ class TencentCloudClient: "gender": getattr(person_info, "Gender", 0), "face_ids": getattr(person_info, "FaceIds", []), }) - return persons + return { + "success": True, + "persons": persons, + "error": None, + "error_code": None, + "error_message": None + } except Exception as ex: duration = time.time() - start_time self._log_error("获取人员列表", request_id, ex, duration) - raise + + error_code = None + error_message = str(ex) + + if isinstance(ex, TencentCloudSDKException): + error_code = getattr(ex, "code", None) + error_message = getattr(ex, "message", str(ex)) + + return { + "success": False, + "persons": [], + "error": str(ex), + "error_code": error_code, + "error_message": error_message + } return self._execute_with_retry(api_call, "获取人员列表") - def get_person_list_all(self, group_id: str) -> List[Dict[str, Any]]: + def get_person_list_all(self, group_id: str) -> Dict[str, Any]: """获取人员列表(自动分页获取全部)""" all_persons = [] offset = 0 limit = 100 while True: - persons = self.get_person_list(group_id, limit=limit, offset=offset) + result = self.get_person_list(group_id, limit=limit, offset=offset) + if not result.get("success", False): + return result + persons = result.get("persons", []) all_persons.extend(persons) if len(persons) < limit: break offset += limit - return all_persons + return { + "success": True, + "persons": all_persons, + "error": None, + "error_code": None, + "error_message": None + } def _execute_with_retry(self, api_call: Callable, operation_name: str) -> Any: """执行带重试的API调用,max_attempts 为总尝试次数(1次初始 + N次重试)"""