refactor: 全面重构腾讯云人脸识别插件
- 拆分 tencent_cloud_client.py(1311行)为 api_operations.py / image_processor.py / retry.py - 更新 HA 废弃 API: SupportsResponse.OPTIONAL, async_unload_platforms, native_value, 移除 CONNECTION_CLASS - 新增动态人员传感器管理(自动增/删) - 新增 detect_face 服务定义(services.yaml) - 移除 secret_key 持久化存储,增强安全性 - 完善错误映射前缀匹配 + 异常类型安全退避 - 修复 Coordinator 双重人员 ID 跟踪死代码 - 添加 _sanitize_error 脱敏,ImageCache LRU 缓存 - manifest.json 版本 2.1.0 + Pillow 依赖
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
你是一名Home Assistant自定义集成开发专家。请全面审查并修复 /workspace/tencent_face_recognition/ 下的腾讯云人脸识别插件。
|
||||
|
||||
## 项目结构
|
||||
- manifest.json: 插件清单
|
||||
- const.py: 常量和默认值
|
||||
- __init__.py: 入口,async_setup_entry/async_unload_entry
|
||||
- config_flow.py: 配置流程
|
||||
- errors.py: 错误处理
|
||||
- face_recognition.py: 人脸识别核心功能
|
||||
- sensor.py: 传感器实体
|
||||
- services.py: 服务定义
|
||||
- tencent_cloud_client.py: 腾讯云API客户端(1311行)
|
||||
- strings.json: 字符串翻译
|
||||
|
||||
## 已知问题需要修复
|
||||
|
||||
### 1. tencent_cloud_client.py
|
||||
- 文件太大(1311行),拆分为多个文件
|
||||
- 将 get_group_info, get_person_list_all, verify_credentials 等方法提取到独立模块
|
||||
- 图片压缩/验证逻辑太复杂,简化
|
||||
- 重试逻辑可复用,简化
|
||||
|
||||
### 2. __init__.py
|
||||
- async_forward_entry_setups 在新版HA中需要确认API
|
||||
- ConfigEntryNotReady 使用是否正确
|
||||
- 缺少异常处理边界
|
||||
|
||||
### 3. sensor.py
|
||||
- CoordinatorEntity 使用是否正确
|
||||
- 传感器属性更新机制
|
||||
- 人员删除后传感器需要正确处理
|
||||
|
||||
### 4. services.py
|
||||
- supports_response 在最新HA中的兼容性
|
||||
- 服务注册模式是否正确
|
||||
|
||||
### 5. config_flow.py
|
||||
- CONNECTION_CLASS 已废弃,需要更新
|
||||
- 配置验证流程优化
|
||||
|
||||
### 6. const.py
|
||||
- 添加 missing constants
|
||||
- 更新默认值
|
||||
|
||||
## 修复原则
|
||||
1. 保持向后兼容性
|
||||
2. 使用最新的 Home Assistant 2025+ API
|
||||
3. 使用最新的腾讯云 IAI SDK API
|
||||
4. 完善错误处理
|
||||
5. 优化代码结构,合理拆分大文件
|
||||
6. 所有文件保持 UTF-8 编码
|
||||
7. 更新 manifest.json 版本号到 2.1.0
|
||||
|
||||
请开始修复。每次修改一个文件,确保语法正确。
|
||||
+64
-24
@@ -1,44 +1,60 @@
|
||||
"""
|
||||
腾讯云人脸识别Home Assistant插件
|
||||
腾讯云人脸识别 Home Assistant 插件
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from typing import Any, Dict
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
CONF_PERSON_GROUP_ID,
|
||||
CONF_REGION,
|
||||
CONF_SECRET_ID,
|
||||
CONF_SECRET_KEY,
|
||||
CONF_REGION,
|
||||
CONF_PERSON_GROUP_ID,
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
get_entry_value,
|
||||
)
|
||||
from .tencent_cloud_client import TencentCloudClient
|
||||
from .errors import AuthenticationError, NetworkError
|
||||
from .face_recognition import FaceRecognition
|
||||
from .services import async_setup_services, async_unload_services
|
||||
from .tencent_cloud_client import TencentCloudClient
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""设置配置项"""
|
||||
_LOGGER.info("设置腾讯云人脸识别插件")
|
||||
"""设置配置项。"""
|
||||
_LOGGER.info("设置腾讯云人脸识别插件: %s", entry.entry_id)
|
||||
|
||||
secret_id = entry.data.get(CONF_SECRET_ID)
|
||||
secret_key = entry.data.get(CONF_SECRET_KEY)
|
||||
region = get_entry_value(entry, CONF_REGION, "ap-shanghai")
|
||||
person_group_id = get_entry_value(entry, CONF_PERSON_GROUP_ID, "Hass")
|
||||
|
||||
# 1. 创建客户端
|
||||
try:
|
||||
client = TencentCloudClient(secret_id, secret_key, region)
|
||||
except Exception as ex:
|
||||
_LOGGER.error("创建腾讯云客户端失败")
|
||||
raise ConfigEntryNotReady(f"创建腾讯云客户端失败: {ex}")
|
||||
except Exception as ex: # noqa: BLE001
|
||||
_LOGGER.error("创建腾讯云客户端失败: %s", ex)
|
||||
raise ConfigEntryNotReady(f"创建腾讯云客户端失败: {ex}") from ex
|
||||
|
||||
# 2. 验证凭据(失败则让 HA 稍后重试 setup)
|
||||
try:
|
||||
await hass.async_add_executor_job(client.verify_credentials)
|
||||
except (AuthenticationError, NetworkError) as ex:
|
||||
_LOGGER.error("腾讯云凭据验证失败: %s", ex)
|
||||
await hass.async_add_executor_job(client.close)
|
||||
raise ConfigEntryNotReady(f"腾讯云凭据验证失败: {ex}") from ex
|
||||
except Exception as ex: # noqa: BLE001
|
||||
_LOGGER.warning("腾讯云凭据验证异常(将继续加载): %s", ex)
|
||||
|
||||
# 3. 构建核心功能与存储
|
||||
face_recognition = FaceRecognition(hass, client)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
|
||||
@@ -46,35 +62,59 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"client": client,
|
||||
"face_recognition": face_recognition,
|
||||
"secret_id": secret_id,
|
||||
"secret_key": secret_key,
|
||||
"region": region,
|
||||
"person_group_id": person_group_id
|
||||
"person_group_id": person_group_id,
|
||||
}
|
||||
|
||||
# 4. 注册服务
|
||||
await async_setup_services(hass)
|
||||
|
||||
# 5. 前向设置平台(sensor)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# 6. 配置项更新时自动重新加载平台
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
|
||||
_LOGGER.info("腾讯云人脸识别插件设置完成")
|
||||
return True
|
||||
|
||||
|
||||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""配置项选项变更时重新加载。"""
|
||||
_LOGGER.debug("配置项变更,重新加载: %s", entry.entry_id)
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""卸载配置项"""
|
||||
_LOGGER.info("卸载腾讯云人脸识别插件")
|
||||
"""卸载配置项。"""
|
||||
_LOGGER.info("卸载腾讯云人脸识别插件: %s", entry.entry_id)
|
||||
|
||||
unload_ok = await hass.config_entries.async_forward_entry_unload(entry, "sensor")
|
||||
# 使用统一的卸载 API(兼容新版 HA)
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
await async_unload_services(hass)
|
||||
entry_data: Dict[str, Any] = hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
||||
|
||||
entry_data = hass.data[DOMAIN].pop(entry.entry_id, None)
|
||||
if entry_data and "client" in entry_data:
|
||||
client = entry_data["client"]
|
||||
if entry_data and "client" in entry_data:
|
||||
client: TencentCloudClient = entry_data["client"]
|
||||
try:
|
||||
await hass.async_add_executor_job(client.close)
|
||||
except Exception as ex: # noqa: BLE001
|
||||
_LOGGER.warning("关闭腾讯云客户端时出错: %s", ex)
|
||||
|
||||
# 若已无该域的配置项,则卸载服务
|
||||
if not hass.data.get(DOMAIN):
|
||||
await async_unload_services(hass)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""移除配置项"""
|
||||
_LOGGER.info("移除腾讯云人脸识别插件配置")
|
||||
"""移除配置项。"""
|
||||
_LOGGER.info("移除腾讯云人脸识别插件配置: %s", entry.entry_id)
|
||||
# 客户端资源已在 unload 时释放;这里仅做兜底清理
|
||||
data = hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
||||
if data and "client" in data:
|
||||
try:
|
||||
await hass.async_add_executor_job(data["client"].close)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
"""API 操作实现。
|
||||
|
||||
将原 ``tencent_cloud_client.py`` 中各 API 调用方法(搜索/检测/属性/创建人员/
|
||||
删除人员/注册人脸/删除人脸/获取库信息/获取人员列表等)抽离为独立函数,
|
||||
由 ``TencentCloudClient`` 委托调用。每个函数返回标准化的结果字典。
|
||||
|
||||
腾讯云 IAI SDK (``tencentcloud.iai.v20200303``) 的若干字段名已更新:
|
||||
- ``NeedRotateCheck`` 已废弃,使用 ``NeedRotateDetection``。
|
||||
- ``CreateFaceRequest`` 使用 ``Images``(列表)而非 ``Image``。
|
||||
- ``Candidate`` 没有 ``PersonTag`` 字段(标签在 ``PersonGroupInfos`` 中)。
|
||||
- ``FaceInfo`` 的属性位于 ``FaceAttributesInfo`` / ``FaceQualityInfo`` 子结构。
|
||||
- ``GetGroupInfoResponse`` 的库标签字段是 ``Tag`` 而非 ``GroupTag``。
|
||||
- ``CreateFaceResponse`` 的成功人脸 ID 列表是 ``SucFaceIds`` 而非 ``FaceIds``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from tencentcloud.iai.v20200303 import models
|
||||
|
||||
from .retry import RetryConfig, execute_with_retry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# 是否记录请求/响应载荷详情(调试用)
|
||||
ENABLE_REQUEST_LOGGING = False
|
||||
ENABLE_RESPONSE_LOGGING = False
|
||||
ENABLE_PERFORMANCE_LOGGING = True
|
||||
LOG_PAYLOAD_MAX_SIZE = 1024
|
||||
|
||||
|
||||
def _log_request(operation: str, params: Dict[str, Any], request_id: str) -> None:
|
||||
if not ENABLE_REQUEST_LOGGING:
|
||||
return
|
||||
safe_params = {}
|
||||
for key, value in params.items():
|
||||
if key in ("Image", "Images", "Url"):
|
||||
safe_params[key] = f"<image_{len(str(value))}_chars>"
|
||||
else:
|
||||
safe_params[key] = value
|
||||
try:
|
||||
params_str = json.dumps(safe_params, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
params_str = str(safe_params)
|
||||
if len(params_str) > LOG_PAYLOAD_MAX_SIZE:
|
||||
params_str = params_str[:LOG_PAYLOAD_MAX_SIZE] + "...[truncated]"
|
||||
_LOGGER.debug("API请求: op=%s id=%s params=%s", operation, request_id, params_str)
|
||||
|
||||
|
||||
def _log_response(operation: str, request_id: str, response: Any, duration: float) -> None:
|
||||
if ENABLE_PERFORMANCE_LOGGING:
|
||||
if duration > 5.0:
|
||||
_LOGGER.warning("API响应缓慢: op=%s id=%s duration=%.3fs", operation, request_id, duration)
|
||||
elif duration > 2.0:
|
||||
_LOGGER.info("API响应较慢: op=%s id=%s duration=%.3fs", operation, request_id, duration)
|
||||
if not ENABLE_RESPONSE_LOGGING:
|
||||
return
|
||||
try:
|
||||
resp_dict = {}
|
||||
for attr in dir(response):
|
||||
if attr.startswith("_") or callable(getattr(response, attr)):
|
||||
continue
|
||||
resp_dict[attr] = getattr(response, attr)
|
||||
resp_str = json.dumps(resp_dict, ensure_ascii=False, default=str)
|
||||
except Exception: # noqa: BLE001
|
||||
resp_str = str(response)
|
||||
if len(resp_str) > LOG_PAYLOAD_MAX_SIZE:
|
||||
resp_str = resp_str[:LOG_PAYLOAD_MAX_SIZE] + "...[truncated]"
|
||||
_LOGGER.debug("API响应: op=%s id=%s response=%s", operation, request_id, resp_str)
|
||||
|
||||
|
||||
def _log_error(operation: str, request_id: str, error: Exception, duration: float, sanitize=None) -> None:
|
||||
sanitize = sanitize or (lambda s: s)
|
||||
_LOGGER.error(
|
||||
"API错误: op=%s id=%s duration=%.3fs error_type=%s msg=%s",
|
||||
operation, request_id, duration, type(error).__name__,
|
||||
sanitize(str(error)),
|
||||
)
|
||||
# SDK 异常的 code 在 TencentCloudSDKException 上
|
||||
code = getattr(error, "code", None)
|
||||
if code:
|
||||
_LOGGER.error("腾讯云API错误详情: op=%s id=%s code=%s", operation, request_id, code)
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
_LOGGER.debug("API错误堆栈: op=%s id=%s", operation, request_id, exc_info=True)
|
||||
|
||||
|
||||
def _build_error_result(ex: Exception, **extra: Any) -> Dict[str, Any]:
|
||||
"""构造失败结果字典。"""
|
||||
from tencentcloud.common.exception.tencent_cloud_sdk_exception import (
|
||||
TencentCloudSDKException,
|
||||
)
|
||||
result: Dict[str, Any] = {"success": False}
|
||||
result.update(extra)
|
||||
if isinstance(ex, TencentCloudSDKException):
|
||||
result["error_code"] = getattr(ex, "code", None)
|
||||
result["error_message"] = getattr(ex, "message", str(ex))
|
||||
else:
|
||||
result["error_code"] = None
|
||||
result["error_message"] = str(ex)
|
||||
result["error"] = str(ex)
|
||||
return result
|
||||
|
||||
|
||||
def _run(
|
||||
client,
|
||||
operation_name: str,
|
||||
build_request,
|
||||
send,
|
||||
parse_response,
|
||||
*,
|
||||
retry_config: Optional[RetryConfig] = None,
|
||||
sanitize=None,
|
||||
success_extra: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""统一的 API 调用包装:构建请求 -> 发送 -> 解析响应,带重试。
|
||||
|
||||
- ``build_request()`` 返回 ``(models.XxxRequest, params_dict)``。
|
||||
- ``send(req)`` 调用 client 的实际 SDK 方法,返回 response。
|
||||
- ``parse_response(resp)`` 返回业务结果字典(成功部分)。
|
||||
"""
|
||||
|
||||
def api_call() -> Dict[str, Any]:
|
||||
request_id = str(uuid.uuid4())
|
||||
start = time.time()
|
||||
try:
|
||||
req, params = build_request()
|
||||
_log_request(operation_name, params, request_id)
|
||||
resp = send(req)
|
||||
duration = time.time() - start
|
||||
_log_response(operation_name, request_id, resp, duration)
|
||||
result = {"success": True}
|
||||
if success_extra:
|
||||
result.update(success_extra)
|
||||
result.update(parse_response(resp))
|
||||
return result
|
||||
except Exception as ex: # noqa: BLE001
|
||||
duration = time.time() - start
|
||||
_log_error(operation_name, request_id, ex, duration, sanitize)
|
||||
# 抛出交由 execute_with_retry 决定是否重试
|
||||
raise
|
||||
|
||||
return execute_with_retry(
|
||||
api_call, operation_name, config=retry_config, sanitize_error=sanitize
|
||||
)
|
||||
|
||||
|
||||
# --- 各操作实现 -----------------------------------------------------------
|
||||
|
||||
|
||||
def search_faces(
|
||||
client,
|
||||
*,
|
||||
image_base64: str,
|
||||
group_ids: List[str],
|
||||
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,
|
||||
retry_config: Optional[RetryConfig] = None,
|
||||
sanitize=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""搜索人脸。"""
|
||||
|
||||
def build_request():
|
||||
req = models.SearchFacesRequest()
|
||||
params = {
|
||||
"NeedPersonInfo": 1,
|
||||
"Image": image_base64,
|
||||
"MaxFaceNum": max_face_num,
|
||||
"MinFaceSize": min_face_size,
|
||||
"MaxPersonNum": max_user_num,
|
||||
"QualityControl": quality_control,
|
||||
"NeedRotateDetection": need_rotate_check,
|
||||
"FaceMatchThreshold": face_match_threshold,
|
||||
"GroupIds": group_ids,
|
||||
}
|
||||
req.from_json_string(json.dumps(params))
|
||||
return req, params
|
||||
|
||||
def parse_response(resp):
|
||||
results: List[Dict[str, Any]] = []
|
||||
for result in getattr(resp, "Results", []) or []:
|
||||
face_result: Dict[str, Any] = {
|
||||
"face_id": None,
|
||||
"candidates": [],
|
||||
"face_rect": _face_rect_dict(getattr(result, "FaceRect", None)),
|
||||
}
|
||||
for candidate in getattr(result, "Candidates", []) or []:
|
||||
face_result["candidates"].append({
|
||||
"person_id": getattr(candidate, "PersonId", None),
|
||||
"person_name": getattr(candidate, "PersonName", None),
|
||||
"score": getattr(candidate, "Score", None),
|
||||
"face_id": getattr(candidate, "FaceId", None),
|
||||
"gender": getattr(candidate, "Gender", None),
|
||||
"person_group_infos": _person_group_infos(
|
||||
getattr(candidate, "PersonGroupInfos", None)
|
||||
),
|
||||
})
|
||||
results.append(face_result)
|
||||
return {"faces": results, "face_count": len(results)}
|
||||
|
||||
return _run(
|
||||
client, "人脸搜索", build_request,
|
||||
lambda req: client.SearchFaces(req), parse_response,
|
||||
retry_config=retry_config, sanitize=sanitize,
|
||||
)
|
||||
|
||||
|
||||
def detect_faces(
|
||||
client,
|
||||
*,
|
||||
image_base64: str,
|
||||
max_face_num: int = 1,
|
||||
min_face_size: int = 34,
|
||||
need_rotate_check: int = 1,
|
||||
retry_config: Optional[RetryConfig] = None,
|
||||
sanitize=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""检测人脸(不返回属性)。"""
|
||||
|
||||
def build_request():
|
||||
req = models.DetectFaceRequest()
|
||||
params = {
|
||||
"Image": image_base64,
|
||||
"MaxFaceNum": max_face_num,
|
||||
"MinFaceSize": min_face_size,
|
||||
"NeedRotateDetection": need_rotate_check,
|
||||
}
|
||||
req.from_json_string(json.dumps(params))
|
||||
return req, params
|
||||
|
||||
def parse_response(resp):
|
||||
results = []
|
||||
for info in getattr(resp, "FaceInfos", []) or []:
|
||||
results.append({
|
||||
"x": getattr(info, "X", 0),
|
||||
"y": getattr(info, "Y", 0),
|
||||
"width": getattr(info, "Width", 0),
|
||||
"height": getattr(info, "Height", 0),
|
||||
})
|
||||
return {"faces": results, "face_count": len(results),
|
||||
"image_width": getattr(resp, "ImageWidth", 0),
|
||||
"image_height": getattr(resp, "ImageHeight", 0)}
|
||||
|
||||
return _run(
|
||||
client, "人脸检测", build_request,
|
||||
lambda req: client.DetectFace(req), parse_response,
|
||||
retry_config=retry_config, sanitize=sanitize,
|
||||
)
|
||||
|
||||
|
||||
def get_face_attributes(
|
||||
client,
|
||||
*,
|
||||
image_base64: str,
|
||||
max_face_num: int = 1,
|
||||
need_rotate_check: int = 1,
|
||||
retry_config: Optional[RetryConfig] = None,
|
||||
sanitize=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""获取人脸属性。"""
|
||||
|
||||
def build_request():
|
||||
req = models.DetectFaceRequest()
|
||||
params = {
|
||||
"Image": image_base64,
|
||||
"MaxFaceNum": max_face_num,
|
||||
"NeedRotateDetection": need_rotate_check,
|
||||
"NeedFaceAttributes": 1,
|
||||
"NeedQualityDetection": 1,
|
||||
}
|
||||
req.from_json_string(json.dumps(params))
|
||||
return req, params
|
||||
|
||||
def parse_response(resp):
|
||||
results = []
|
||||
for info in getattr(resp, "FaceInfos", []) or []:
|
||||
attrs = getattr(info, "FaceAttributesInfo", None)
|
||||
quality = getattr(info, "FaceQualityInfo", None)
|
||||
results.append({
|
||||
"x": getattr(info, "X", 0),
|
||||
"y": getattr(info, "Y", 0),
|
||||
"width": getattr(info, "Width", 0),
|
||||
"height": getattr(info, "Height", 0),
|
||||
"gender": getattr(attrs, "Gender", None) if attrs else None,
|
||||
"age": getattr(attrs, "Age", None) if attrs else None,
|
||||
"expression": getattr(attrs, "Expression", None) if attrs else None,
|
||||
"beauty": getattr(attrs, "Beauty", None) if attrs else None,
|
||||
"glass": getattr(attrs, "Glass", None) if attrs else None,
|
||||
"pitch": getattr(attrs, "Pitch", None) if attrs else None,
|
||||
"yaw": getattr(attrs, "Yaw", None) if attrs else None,
|
||||
"roll": getattr(attrs, "Roll", None) if attrs else None,
|
||||
"eye_open": getattr(attrs, "EyeOpen", None) if attrs else None,
|
||||
"quality_score": getattr(quality, "Score", None) if quality else None,
|
||||
"quality_brightness": getattr(quality, "Brightness", None) if quality else None,
|
||||
"quality_sharpness": getattr(quality, "Sharpness", None) if quality else None,
|
||||
})
|
||||
return {"faces": results, "face_count": len(results),
|
||||
"image_width": getattr(resp, "ImageWidth", 0),
|
||||
"image_height": getattr(resp, "ImageHeight", 0)}
|
||||
|
||||
return _run(
|
||||
client, "获取人脸属性", build_request,
|
||||
lambda req: client.DetectFace(req), parse_response,
|
||||
retry_config=retry_config, sanitize=sanitize,
|
||||
)
|
||||
|
||||
|
||||
def create_person(
|
||||
client,
|
||||
*,
|
||||
person_id: str,
|
||||
person_name: str,
|
||||
group_id: str,
|
||||
image_base64: Optional[str] = None,
|
||||
gender: Optional[int] = None,
|
||||
person_tag: Optional[str] = None,
|
||||
quality_control: int = 1,
|
||||
need_rotate_check: int = 1,
|
||||
retry_config: Optional[RetryConfig] = None,
|
||||
sanitize=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""创建人员。"""
|
||||
|
||||
def build_request():
|
||||
req = models.CreatePersonRequest()
|
||||
params: Dict[str, Any] = {
|
||||
"PersonId": person_id,
|
||||
"PersonName": person_name,
|
||||
"GroupId": group_id,
|
||||
"QualityControl": quality_control,
|
||||
"NeedRotateDetection": need_rotate_check,
|
||||
}
|
||||
if image_base64:
|
||||
params["Image"] = image_base64
|
||||
if gender is not None:
|
||||
params["Gender"] = gender
|
||||
# ``PersonTag`` 字段在 SDK 中已废弃,人员备注需通过
|
||||
# ``PersonExDescriptionInfos``(外部描述列表)存储。
|
||||
if person_tag is not None:
|
||||
params["PersonExDescriptionInfos"] = [
|
||||
{
|
||||
"PersonExDescriptionIndex": 0,
|
||||
"PersonExDescription": person_tag,
|
||||
}
|
||||
]
|
||||
req.from_json_string(json.dumps(params))
|
||||
return req, params
|
||||
|
||||
def parse_response(resp):
|
||||
return {
|
||||
"person_id": getattr(resp, "PersonId", person_id),
|
||||
"face_id": getattr(resp, "FaceId", ""),
|
||||
"face_rect": _face_rect_dict(getattr(resp, "FaceRect", None)),
|
||||
"face_model_version": getattr(resp, "FaceModelVersion", ""),
|
||||
}
|
||||
|
||||
return _run(
|
||||
client, "创建人员", build_request,
|
||||
lambda req: client.CreatePerson(req), parse_response,
|
||||
retry_config=retry_config, sanitize=sanitize,
|
||||
)
|
||||
|
||||
|
||||
def delete_person(
|
||||
client,
|
||||
*,
|
||||
person_id: str,
|
||||
retry_config: Optional[RetryConfig] = None,
|
||||
sanitize=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""删除人员。"""
|
||||
|
||||
def build_request():
|
||||
req = models.DeletePersonRequest()
|
||||
params = {"PersonId": person_id}
|
||||
req.from_json_string(json.dumps(params))
|
||||
return req, params
|
||||
|
||||
return _run(
|
||||
client, "删除人员", build_request,
|
||||
lambda req: client.DeletePerson(req), lambda resp: {"person_id": person_id},
|
||||
retry_config=retry_config, sanitize=sanitize,
|
||||
)
|
||||
|
||||
|
||||
def create_face(
|
||||
client,
|
||||
*,
|
||||
person_id: str,
|
||||
image_base64: str,
|
||||
quality_control: int = 1,
|
||||
need_rotate_check: int = 1,
|
||||
retry_config: Optional[RetryConfig] = None,
|
||||
sanitize=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""为人员注册人脸。
|
||||
|
||||
``CreateFaceRequest`` 接受 ``Images``(列表),单张图片包装为单元素列表。
|
||||
"""
|
||||
|
||||
def build_request():
|
||||
req = models.CreateFaceRequest()
|
||||
params = {
|
||||
"PersonId": person_id,
|
||||
"Images": [image_base64],
|
||||
"QualityControl": quality_control,
|
||||
"NeedRotateDetection": need_rotate_check,
|
||||
}
|
||||
req.from_json_string(json.dumps(params))
|
||||
return req, params
|
||||
|
||||
def parse_response(resp):
|
||||
return {
|
||||
"person_id": person_id,
|
||||
"face_ids": list(getattr(resp, "SucFaceIds", []) or []),
|
||||
"face_rects": [_face_rect_dict(r) for r in (getattr(resp, "SucFaceRects", []) or [])],
|
||||
"suc_face_num": getattr(resp, "SucFaceNum", 0),
|
||||
"ret_code": getattr(resp, "RetCode", None),
|
||||
"face_model_version": getattr(resp, "FaceModelVersion", ""),
|
||||
}
|
||||
|
||||
return _run(
|
||||
client, "注册人脸", build_request,
|
||||
lambda req: client.CreateFace(req), parse_response,
|
||||
retry_config=retry_config, sanitize=sanitize,
|
||||
)
|
||||
|
||||
|
||||
def delete_face(
|
||||
client,
|
||||
*,
|
||||
person_id: str,
|
||||
face_id: str,
|
||||
retry_config: Optional[RetryConfig] = None,
|
||||
sanitize=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""删除人脸。``DeleteFaceRequest`` 接受 ``FaceIds``(列表)。"""
|
||||
|
||||
def build_request():
|
||||
req = models.DeleteFaceRequest()
|
||||
params = {"PersonId": person_id, "FaceIds": [face_id]}
|
||||
req.from_json_string(json.dumps(params))
|
||||
return req, params
|
||||
|
||||
return _run(
|
||||
client, "删除人脸", build_request,
|
||||
lambda req: client.DeleteFace(req),
|
||||
lambda resp: {"person_id": person_id, "face_id": face_id,
|
||||
"suc_indexes": list(getattr(resp, "SucIndexes", []) or [])},
|
||||
retry_config=retry_config, sanitize=sanitize,
|
||||
)
|
||||
|
||||
|
||||
def get_group_info(
|
||||
client,
|
||||
*,
|
||||
group_id: str,
|
||||
retry_config: Optional[RetryConfig] = None,
|
||||
sanitize=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""获取人员库信息。"""
|
||||
|
||||
def build_request():
|
||||
req = models.GetGroupInfoRequest()
|
||||
params = {"GroupId": group_id}
|
||||
req.from_json_string(json.dumps(params))
|
||||
return req, params
|
||||
|
||||
def parse_response(resp):
|
||||
return {
|
||||
"group_id": getattr(resp, "GroupId", group_id),
|
||||
"group_name": getattr(resp, "GroupName", ""),
|
||||
# SDK 字段名为 Tag(库标签),原代码误用 GroupTag
|
||||
"group_tag": getattr(resp, "Tag", ""),
|
||||
"face_model_version": getattr(resp, "FaceModelVersion", ""),
|
||||
"creation_timestamp": getattr(resp, "CreationTimestamp", 0),
|
||||
}
|
||||
|
||||
return _run(
|
||||
client, "获取人员库信息", build_request,
|
||||
lambda req: client.GetGroupInfo(req), parse_response,
|
||||
retry_config=retry_config, sanitize=sanitize,
|
||||
)
|
||||
|
||||
|
||||
def get_person_list(
|
||||
client,
|
||||
*,
|
||||
group_id: str,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
retry_config: Optional[RetryConfig] = None,
|
||||
sanitize=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""获取人员列表(单页)。"""
|
||||
|
||||
def build_request():
|
||||
req = models.GetPersonListRequest()
|
||||
params = {"GroupId": group_id, "Limit": limit, "Offset": offset}
|
||||
req.from_json_string(json.dumps(params))
|
||||
return req, params
|
||||
|
||||
def parse_response(resp):
|
||||
persons = []
|
||||
for info in getattr(resp, "PersonInfos", []) or []:
|
||||
persons.append({
|
||||
"person_id": getattr(info, "PersonId", ""),
|
||||
"person_name": getattr(info, "PersonName", ""),
|
||||
"gender": getattr(info, "Gender", 0),
|
||||
"face_ids": list(getattr(info, "FaceIds", []) or []),
|
||||
"creation_timestamp": getattr(info, "CreationTimestamp", 0),
|
||||
})
|
||||
return {
|
||||
"persons": persons,
|
||||
"person_num": getattr(resp, "PersonNum", len(persons)),
|
||||
"face_model_version": getattr(resp, "FaceModelVersion", ""),
|
||||
}
|
||||
|
||||
return _run(
|
||||
client, "获取人员列表", build_request,
|
||||
lambda req: client.GetPersonList(req), parse_response,
|
||||
retry_config=retry_config, sanitize=sanitize,
|
||||
)
|
||||
|
||||
|
||||
def get_person_list_all(
|
||||
client,
|
||||
*,
|
||||
group_id: str,
|
||||
limit: int = 100,
|
||||
retry_config: Optional[RetryConfig] = None,
|
||||
sanitize=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""获取人员列表(自动分页获取全部)。"""
|
||||
all_persons: List[Dict[str, Any]] = []
|
||||
offset = 0
|
||||
face_model_version = ""
|
||||
while True:
|
||||
page = get_person_list(
|
||||
client, group_id=group_id, limit=limit, offset=offset,
|
||||
retry_config=retry_config, sanitize=sanitize,
|
||||
)
|
||||
if not page.get("success", False):
|
||||
return page
|
||||
persons = page.get("persons", [])
|
||||
all_persons.extend(persons)
|
||||
face_model_version = page.get("face_model_version", face_model_version)
|
||||
# 单页不足 limit 视为末页
|
||||
if len(persons) < limit:
|
||||
break
|
||||
offset += limit
|
||||
# 防御:person_num 已达上限则停止
|
||||
person_num = page.get("person_num", 0)
|
||||
if person_num and len(all_persons) >= person_num:
|
||||
break
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"persons": all_persons,
|
||||
"person_count": len(all_persons),
|
||||
"face_model_version": face_model_version,
|
||||
"error": None,
|
||||
"error_code": None,
|
||||
"error_message": None,
|
||||
}
|
||||
|
||||
|
||||
def verify_credentials(client, retry_config: Optional[RetryConfig] = None) -> bool:
|
||||
"""验证凭据是否有效,通过调用 GetGroupList 轻量测试。
|
||||
|
||||
成功返回 True;失败抛出对应的 ``TencentFaceRecognitionError``。
|
||||
"""
|
||||
|
||||
def api_call() -> bool:
|
||||
req = models.GetGroupListRequest()
|
||||
req.from_json_string(json.dumps({"Limit": 1, "Offset": 0}))
|
||||
client.GetGroupList(req)
|
||||
return True
|
||||
|
||||
execute_with_retry(api_call, "凭据验证", config=retry_config)
|
||||
_LOGGER.info("凭据验证成功")
|
||||
return True
|
||||
|
||||
|
||||
# --- 辅助 -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _face_rect_dict(face_rect) -> Optional[Dict[str, Any]]:
|
||||
"""将 SDK 的 FaceRect 对象转为字典。"""
|
||||
if face_rect is None:
|
||||
return None
|
||||
return {
|
||||
"x": getattr(face_rect, "X", 0),
|
||||
"y": getattr(face_rect, "Y", 0),
|
||||
"width": getattr(face_rect, "Width", 0),
|
||||
"height": getattr(face_rect, "Height", 0),
|
||||
}
|
||||
|
||||
|
||||
def _person_group_infos(infos) -> List[Dict[str, Any]]:
|
||||
"""将 Candidate.PersonGroupInfos 转为列表字典。"""
|
||||
if not infos:
|
||||
return []
|
||||
result = []
|
||||
for info in infos:
|
||||
result.append({
|
||||
"group_id": getattr(info, "GroupId", ""),
|
||||
"person_ex_descriptions": list(getattr(info, "PersonExDescriptions", []) or []),
|
||||
})
|
||||
return result
|
||||
+42
-19
@@ -20,12 +20,11 @@ from .const import (
|
||||
DEFAULT_REGION,
|
||||
)
|
||||
from .errors import (
|
||||
validate_config,
|
||||
InvalidConfigError,
|
||||
AuthenticationError,
|
||||
InvalidConfigError,
|
||||
NetworkError,
|
||||
RateLimitError,
|
||||
log_and_raise_error
|
||||
validate_config,
|
||||
)
|
||||
from .tencent_cloud_client import TencentCloudClient
|
||||
|
||||
@@ -36,7 +35,9 @@ class TencentFaceRecognitionConfigFlow(config_entries.ConfigFlow, domain=DOMAIN)
|
||||
"""腾讯云人脸识别配置流程"""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
|
||||
|
||||
# CONNECTION_CLASS 已在 HA 2024.7 废弃,连接类型现由 manifest.json 的
|
||||
# iot_class 字段声明(本项目为 cloud_polling)。
|
||||
|
||||
AVAILABLE_REGIONS = [
|
||||
"ap-beijing", "ap-shanghai", "ap-guangzhou", "ap-chengdu",
|
||||
@@ -65,6 +66,12 @@ class TencentFaceRecognitionConfigFlow(config_entries.ConfigFlow, domain=DOMAIN)
|
||||
|
||||
validate_config(user_input)
|
||||
|
||||
# 防止重复配置:以 secret_id 作为唯一标识
|
||||
await self.async_set_unique_id(
|
||||
f"{DOMAIN}_{user_input.get(CONF_SECRET_ID)}"
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
validation_result = await self._validate_tencent_cloud_credentials(user_input)
|
||||
|
||||
if not validation_result["success"]:
|
||||
@@ -137,6 +144,7 @@ class TencentFaceRecognitionConfigFlow(config_entries.ConfigFlow, domain=DOMAIN)
|
||||
|
||||
async def _validate_tencent_cloud_credentials(self, user_input: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""验证腾讯云凭据 - 通过真实API调用验证"""
|
||||
client = None
|
||||
try:
|
||||
_LOGGER.info("开始验证腾讯云凭据")
|
||||
|
||||
@@ -186,6 +194,9 @@ class TencentFaceRecognitionConfigFlow(config_entries.ConfigFlow, domain=DOMAIN)
|
||||
"base": f"腾讯云API验证失败: {str(ex)}"
|
||||
}
|
||||
}
|
||||
finally:
|
||||
if client is not None:
|
||||
await self.hass.async_add_executor_job(client.close)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@@ -252,6 +263,7 @@ class TencentFaceRecognitionOptionsFlow(config_entries.OptionsFlow):
|
||||
errors[CONF_PERSON_GROUP_ID] = "人员库ID只能包含字母、数字、连字符和下划线,长度1-64个字符"
|
||||
|
||||
if not errors:
|
||||
client = None
|
||||
try:
|
||||
client = TencentCloudClient(
|
||||
self._data.get(CONF_SECRET_ID),
|
||||
@@ -287,6 +299,9 @@ class TencentFaceRecognitionOptionsFlow(config_entries.OptionsFlow):
|
||||
except Exception as ex:
|
||||
_LOGGER.error("配置验证失败: %s", ex)
|
||||
errors["base"] = f"配置验证失败: {str(ex)}"
|
||||
finally:
|
||||
if client is not None:
|
||||
await self.hass.async_add_executor_job(client.close)
|
||||
|
||||
options = {
|
||||
vol.Optional(
|
||||
@@ -316,6 +331,7 @@ class TencentFaceRecognitionOptionsFlow(config_entries.OptionsFlow):
|
||||
connection_status = "未知"
|
||||
|
||||
if user_input is None:
|
||||
client = None
|
||||
try:
|
||||
client = TencentCloudClient(
|
||||
self._data.get(CONF_SECRET_ID),
|
||||
@@ -324,7 +340,7 @@ class TencentFaceRecognitionOptionsFlow(config_entries.OptionsFlow):
|
||||
)
|
||||
|
||||
success = await self.hass.async_add_executor_job(
|
||||
client._test_api_connection,
|
||||
client.test_api_connection,
|
||||
self._data.get(CONF_PERSON_GROUP_ID, "Hass")
|
||||
)
|
||||
|
||||
@@ -346,6 +362,9 @@ class TencentFaceRecognitionOptionsFlow(config_entries.OptionsFlow):
|
||||
connection_status = f"测试失败: {str(ex)}"
|
||||
errors["base"] = f"连接测试失败: {str(ex)}"
|
||||
_LOGGER.error("连接测试失败: %s", ex)
|
||||
finally:
|
||||
if client is not None:
|
||||
await self.hass.async_add_executor_job(client.close)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="test_connection",
|
||||
@@ -361,21 +380,22 @@ class TencentFaceRecognitionOptionsFlow(config_entries.OptionsFlow):
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
temp_errors = {}
|
||||
self._validate_input_format(user_input, temp_errors)
|
||||
|
||||
if temp_errors:
|
||||
errors = temp_errors
|
||||
return self.async_show_form(
|
||||
step_id="reauth",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_SECRET_ID, default=self._data.get(CONF_SECRET_ID, "")): str,
|
||||
vol.Required(CONF_SECRET_KEY, default=self._data.get(CONF_SECRET_KEY, "")): str,
|
||||
}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
new_client = None
|
||||
try:
|
||||
temp_errors = {}
|
||||
self._validate_input_format(user_input, temp_errors)
|
||||
|
||||
if temp_errors:
|
||||
errors = temp_errors
|
||||
return self.async_show_form(
|
||||
step_id="reauth",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_SECRET_ID, default=self._data.get(CONF_SECRET_ID, "")): str,
|
||||
vol.Required(CONF_SECRET_KEY, default=self._data.get(CONF_SECRET_KEY, "")): str,
|
||||
}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
new_client = TencentCloudClient(
|
||||
user_input[CONF_SECRET_ID],
|
||||
user_input[CONF_SECRET_KEY],
|
||||
@@ -405,6 +425,9 @@ class TencentFaceRecognitionOptionsFlow(config_entries.OptionsFlow):
|
||||
except Exception as ex:
|
||||
_LOGGER.error("重新认证失败: %s", ex)
|
||||
errors["base"] = f"重新认证失败: {str(ex)}"
|
||||
finally:
|
||||
if new_client is not None:
|
||||
await self.hass.async_add_executor_job(new_client.close)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth",
|
||||
|
||||
@@ -7,6 +7,7 @@ CONF_SECRET_ID = "secret_id"
|
||||
CONF_SECRET_KEY = "secret_key"
|
||||
CONF_REGION = "region"
|
||||
CONF_PERSON_GROUP_ID = "person_group_id"
|
||||
CONF_SCAN_INTERVAL = "scan_interval"
|
||||
|
||||
DEFAULT_REGION = "ap-shanghai"
|
||||
DEFAULT_PERSON_GROUP_ID = "Hass"
|
||||
@@ -16,6 +17,7 @@ DEFAULT_MAX_USER_NUM = 5
|
||||
DEFAULT_QUALITY_CONTROL = 1
|
||||
DEFAULT_NEED_ROTATE_CHECK = 1
|
||||
DEFAULT_FACE_MATCH_THRESHOLD = 60.0
|
||||
DEFAULT_SCAN_INTERVAL = 300 # seconds (5 minutes)
|
||||
|
||||
SERVICE_FACE_SEARCH = "face_search"
|
||||
SERVICE_DETECT_FACE = "detect_face"
|
||||
@@ -45,6 +47,10 @@ ATTR_PERSON_TAG = "person_tag"
|
||||
|
||||
EVENT_FACE_DETECTED = "face_detected"
|
||||
|
||||
# Sensor identifiers
|
||||
SENSOR_STATUS = "status"
|
||||
SENSOR_PERSON = "person"
|
||||
|
||||
ERROR_INVALID_CONFIG = "无效的配置"
|
||||
ERROR_API_ERROR = "API调用错误"
|
||||
ERROR_IMAGE_NOT_FOUND = "图片未找到"
|
||||
|
||||
@@ -258,22 +258,51 @@ class QuotaExceededError(TencentFaceRecognitionError):
|
||||
|
||||
|
||||
def handle_api_error(error_code: str, error_message: str) -> None:
|
||||
"""处理API错误"""
|
||||
"""处理API错误。
|
||||
|
||||
先尝试精确匹配,再用前缀匹配,最后回退到通用 ``ApiError``。
|
||||
"""
|
||||
error_mapping = {
|
||||
"FailedOperation.ImageDecodeFailed": (ImageNotFoundError, "image_decode_failed"),
|
||||
"FailedOperation.ImageSizeTooLarge": (ImageNotFoundError, "image_too_large"),
|
||||
"InvalidParameterValue.NoFaceInPhoto": (FaceNotDetectedError, "face_not_detected"),
|
||||
"FailedOperation.NoFaceInPhoto": (FaceNotDetectedError, "face_not_detected"),
|
||||
"FailedOperation.FaceSizeTooSmall": (FaceNotDetectedError, "face_too_small"),
|
||||
"FailedOperation.PersonNotFound": (PersonNotFoundError, "person_not_found"),
|
||||
"FailedOperation.GroupNotFound": (PersonGroupNotFoundError, "group_not_found"),
|
||||
"FailedOperation.FaceNotFound": (FaceNotFoundError, "face_not_found"),
|
||||
"AuthFailure": (AuthenticationError, "auth_failure"),
|
||||
"FailedOperation.ConflictInstance": (ApiError, "invalid_parameter"),
|
||||
"AuthFailure.SignatureFailure": (AuthenticationError, "auth_failure"),
|
||||
"AuthFailure.SecretIdNotFound": (AuthenticationError, "auth_failure"),
|
||||
"RequestLimitExceeded": (RateLimitError, "rate_limit_exceeded"),
|
||||
"RequestQuotaExceeded": (QuotaExceededError, "quota_exceeded"),
|
||||
"ResourceUnavailable.NetworkError": (NetworkError, "network_error"),
|
||||
}
|
||||
|
||||
error_info = error_mapping.get(error_code, (ApiError, "api_error"))
|
||||
error_class = error_info[0]
|
||||
error_key = error_info[1]
|
||||
error_class = None
|
||||
error_key = None
|
||||
|
||||
if error_code in error_mapping:
|
||||
error_class, error_key = error_mapping[error_code]
|
||||
else:
|
||||
for prefix in ("AuthFailure", "InvalidParameter", "ResourceNotFound",
|
||||
"LimitExceeded", "FailedOperation"):
|
||||
if error_code.startswith(prefix):
|
||||
_LOGGER.debug("按前缀匹配错误码: %s -> %s", error_code, prefix)
|
||||
if prefix == "AuthFailure":
|
||||
error_class, error_key = AuthenticationError, "auth_failure"
|
||||
elif prefix == "InvalidParameter":
|
||||
error_class, error_key = ApiError, "invalid_parameter"
|
||||
elif prefix == "ResourceNotFound":
|
||||
error_class, error_key = ApiError, "api_error"
|
||||
elif prefix == "LimitExceeded":
|
||||
error_class, error_key = RateLimitError, "rate_limit_exceeded"
|
||||
else:
|
||||
error_class, error_key = ApiError, "api_error"
|
||||
break
|
||||
|
||||
if error_class is None:
|
||||
error_class, error_key = ApiError, "api_error"
|
||||
|
||||
_LOGGER.error("腾讯云API错误: %s - %s", error_code, error_message)
|
||||
|
||||
@@ -290,17 +319,28 @@ def handle_api_error(error_code: str, error_message: str) -> None:
|
||||
|
||||
|
||||
def log_and_raise_error(error_class, message: str, details: Dict[str, Any] = None, error_key: Optional[str] = None) -> None:
|
||||
"""记录日志并抛出错误"""
|
||||
if details:
|
||||
_LOGGER.error("%s: %s", error_class.__name__, message)
|
||||
else:
|
||||
_LOGGER.error("%s: %s", error_class.__name__, message)
|
||||
"""记录日志并抛出错误。
|
||||
|
||||
raise error_class(
|
||||
message,
|
||||
error_key=error_key,
|
||||
error_details=details or {}
|
||||
)
|
||||
``error_class`` 必须是 ``TencentFaceRecognitionError``(或 ``HomeAssistantError``)
|
||||
的子类,以保证上层 ``except HomeAssistantError`` 能正确捕获。
|
||||
如果传入的 ``error_class`` 无法构造为带 ``error_key``/``error_details`` 的自定义错误
|
||||
(例如 ``ValueError``),则退化为抛出 ``ApiError``,避免异常类型不可控。
|
||||
"""
|
||||
_LOGGER.error("%s: %s", error_class.__name__, message)
|
||||
|
||||
try:
|
||||
raise error_class(
|
||||
message,
|
||||
error_key=error_key,
|
||||
error_details=details or {}
|
||||
)
|
||||
except TypeError:
|
||||
# error_class 不支持 error_key/error_details 关键字参数
|
||||
raise ApiError(
|
||||
message,
|
||||
error_key=error_key,
|
||||
error_details=details or {}
|
||||
) from None
|
||||
|
||||
|
||||
def validate_config(config: Dict[str, Any]) -> None:
|
||||
|
||||
+6
-3
@@ -53,7 +53,6 @@ class FaceRecognition:
|
||||
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:
|
||||
try:
|
||||
from homeassistant.components.camera import async_get_image
|
||||
@@ -151,11 +150,13 @@ class FaceRecognition:
|
||||
return result
|
||||
|
||||
def _process_search_results(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""规范化搜索结果字段(保留客户端返回的全部信息)。"""
|
||||
processed_results = []
|
||||
for result in results:
|
||||
processed_result = {
|
||||
"face_id": result.get("face_id"),
|
||||
"candidates": []
|
||||
"face_rect": result.get("face_rect"),
|
||||
"candidates": [],
|
||||
}
|
||||
|
||||
for candidate in result.get("candidates", []):
|
||||
@@ -163,7 +164,9 @@ class FaceRecognition:
|
||||
"person_id": candidate.get("person_id"),
|
||||
"person_name": candidate.get("person_name"),
|
||||
"score": candidate.get("score"),
|
||||
"person_tag": candidate.get("person_tag")
|
||||
"face_id": candidate.get("face_id"),
|
||||
"gender": candidate.get("gender"),
|
||||
"person_group_infos": candidate.get("person_group_infos", []),
|
||||
}
|
||||
processed_result["candidates"].append(processed_candidate)
|
||||
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
"""图片处理模块。
|
||||
|
||||
负责从 URL / 本地路径 / 上传文件 / data URL 获取并编码图片为 base64,
|
||||
并在需要时压缩、缩放、转码为 JPEG。原 ``tencent_cloud_client.py`` 中
|
||||
分散的图片逻辑在此集中、简化。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .errors import ImageNotFoundError, NetworkError, log_and_raise_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# 图片限制
|
||||
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
MAX_IMAGE_DIMENSION = 4096
|
||||
SUPPORTED_IMAGE_FORMATS = (".jpg", ".jpeg", ".png", ".bmp", ".gif")
|
||||
IMAGE_COMPRESSION_QUALITY = 85
|
||||
COMPRESS_TARGET_RATIO = 0.8 # 压缩目标 = MAX_IMAGE_SIZE * 0.8
|
||||
|
||||
_URL_PATTERN = re.compile(
|
||||
r"^https?://"
|
||||
r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|"
|
||||
r"localhost|"
|
||||
r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"
|
||||
r"(?::\d+)?"
|
||||
r"(?:/?|[/?]\S*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def is_valid_url(url: str) -> bool:
|
||||
"""验证 URL 格式是否有效。"""
|
||||
return bool(_URL_PATTERN.match(url or ""))
|
||||
|
||||
|
||||
def _pil_available() -> bool:
|
||||
try:
|
||||
import PIL # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _open_image(data: bytes):
|
||||
"""打开图片,返回 PIL Image 对象;PIL 不可用时抛出 ImageNotFoundError。"""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError as ex:
|
||||
_LOGGER.warning(
|
||||
"PIL库(Pillow)未安装,无法进行图片处理。建议安装: pip install Pillow>=10.0.0"
|
||||
)
|
||||
raise ImageNotFoundError(
|
||||
"Pillow 未安装,无法处理图片",
|
||||
error_key="image_decode_failed",
|
||||
error_details={"reason": "pillow_missing"},
|
||||
) from ex
|
||||
return Image.open(io.BytesIO(data))
|
||||
|
||||
|
||||
def process_image_data(image_data: bytes) -> bytes:
|
||||
"""处理图片数据:尺寸校验、缩放、格式转换、压缩。
|
||||
|
||||
PIL 不可用时直接返回原始数据(但仍受调用方的尺寸约束)。
|
||||
"""
|
||||
if not image_data:
|
||||
return image_data
|
||||
|
||||
# 超过限制先压缩
|
||||
if len(image_data) > MAX_IMAGE_SIZE:
|
||||
_LOGGER.warning("图片大小超过限制,尝试压缩: %d 字节", len(image_data))
|
||||
|
||||
if not _pil_available():
|
||||
# 无法压缩时只能按原样返回,由服务端兜底校验
|
||||
return image_data
|
||||
|
||||
try:
|
||||
img = _open_image(image_data)
|
||||
width, height = img.size
|
||||
|
||||
# 尺寸过大则缩放
|
||||
if width > MAX_IMAGE_DIMENSION or height > MAX_IMAGE_DIMENSION:
|
||||
_LOGGER.warning("图片尺寸过大,尝试缩放: %dx%d", width, height)
|
||||
scale = min(
|
||||
MAX_IMAGE_DIMENSION / width,
|
||||
MAX_IMAGE_DIMENSION / height,
|
||||
)
|
||||
new_size = (int(width * scale), int(height * scale))
|
||||
img = img.resize(new_size, _get_resample_filter())
|
||||
_LOGGER.info(
|
||||
"图片缩放完成: %dx%d -> %dx%d",
|
||||
width, height, new_size[0], new_size[1],
|
||||
)
|
||||
|
||||
# 统一转 JPEG
|
||||
output = io.BytesIO()
|
||||
if img.mode in ("RGBA", "LA", "P"):
|
||||
img = img.convert("RGB")
|
||||
img.save(output, format="JPEG", quality=IMAGE_COMPRESSION_QUALITY, optimize=True)
|
||||
result = output.getvalue()
|
||||
|
||||
# 仍然过大则进一步压缩
|
||||
if len(result) > MAX_IMAGE_SIZE:
|
||||
result = _compress_to_fit(img, MAX_IMAGE_SIZE * COMPRESS_TARGET_RATIO)
|
||||
|
||||
_LOGGER.debug("图片处理完成: %d -> %d 字节", len(image_data), len(result))
|
||||
return result
|
||||
|
||||
except ImageNotFoundError:
|
||||
raise
|
||||
except Exception as ex: # noqa: BLE001
|
||||
_LOGGER.warning("图片处理失败,使用原始数据: %s", ex)
|
||||
return image_data
|
||||
|
||||
|
||||
def _get_resample_filter():
|
||||
from PIL import Image
|
||||
# LANCZOS 在不同 Pillow 版本中名称一致
|
||||
return Image.LANCZOS
|
||||
|
||||
|
||||
def _compress_to_fit(img, target_size: int) -> bytes:
|
||||
"""逐步降低质量以将图片压缩到目标大小。"""
|
||||
quality = IMAGE_COMPRESSION_QUALITY
|
||||
last_data = b""
|
||||
while quality >= 30:
|
||||
output = io.BytesIO()
|
||||
out = img
|
||||
if out.mode in ("RGBA", "LA", "P"):
|
||||
out = out.convert("RGB")
|
||||
out.save(output, format="JPEG", quality=quality, optimize=True)
|
||||
data = output.getvalue()
|
||||
last_data = data
|
||||
if len(data) <= target_size:
|
||||
_LOGGER.info(
|
||||
"图片压缩完成,最终质量: %d, 大小: %d 字节", quality, len(data)
|
||||
)
|
||||
return data
|
||||
quality -= 5
|
||||
_LOGGER.warning("图片压缩已达最低质量,仍超过目标大小: %d 字节", len(last_data))
|
||||
return last_data
|
||||
|
||||
|
||||
def download_image_as_base64(image_url: str, session: Optional[requests.Session] = None) -> str:
|
||||
"""下载图片并转换为 base64 编码。"""
|
||||
_LOGGER.debug("开始下载图片: %s", image_url)
|
||||
|
||||
if not is_valid_url(image_url):
|
||||
log_and_raise_error(
|
||||
ImageNotFoundError,
|
||||
f"无效的图片URL: {image_url}",
|
||||
{"url": image_url},
|
||||
)
|
||||
|
||||
sess = session or requests
|
||||
headers = {"User-Agent": "Mozilla/5.0 (HomeAssistant TencentFaceRecognition)"}
|
||||
|
||||
try:
|
||||
response = sess.get(image_url, timeout=15, headers=headers)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.Timeout as ex:
|
||||
log_and_raise_error(
|
||||
NetworkError,
|
||||
f"下载图片超时: {image_url}",
|
||||
{"url": image_url, "error": "timeout"},
|
||||
)
|
||||
raise # log_and_raise_error 抛出,此行仅为类型提示
|
||||
except requests.exceptions.ConnectionError as ex:
|
||||
log_and_raise_error(
|
||||
NetworkError,
|
||||
f"下载图片连接错误: {image_url}",
|
||||
{"url": image_url, "error": "connection_error"},
|
||||
)
|
||||
raise
|
||||
except requests.exceptions.RequestException as ex:
|
||||
log_and_raise_error(
|
||||
NetworkError,
|
||||
f"下载图片失败: {image_url}",
|
||||
{"url": image_url, "error": str(ex)},
|
||||
)
|
||||
raise
|
||||
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
if content_type and not content_type.startswith("image/"):
|
||||
log_and_raise_error(
|
||||
ImageNotFoundError,
|
||||
f"URL不是有效的图片: {image_url}, 内容类型: {content_type}",
|
||||
{"url": image_url, "content_type": content_type},
|
||||
)
|
||||
|
||||
image_data = process_image_data(response.content)
|
||||
return base64.b64encode(image_data).decode("utf-8")
|
||||
|
||||
|
||||
def read_local_image_as_base64(image_path: str) -> str:
|
||||
"""读取本地图片并转换为 base64 编码。"""
|
||||
if not os.path.exists(image_path):
|
||||
log_and_raise_error(
|
||||
ImageNotFoundError,
|
||||
f"图片文件不存在: {image_path}",
|
||||
{"path": image_path},
|
||||
)
|
||||
if not os.path.isfile(image_path):
|
||||
log_and_raise_error(
|
||||
ImageNotFoundError,
|
||||
f"路径不是文件: {image_path}",
|
||||
{"path": image_path},
|
||||
)
|
||||
|
||||
file_ext = os.path.splitext(image_path)[1].lower()
|
||||
if file_ext not in SUPPORTED_IMAGE_FORMATS:
|
||||
log_and_raise_error(
|
||||
ImageNotFoundError,
|
||||
f"不支持的图片格式: {file_ext}",
|
||||
{"path": image_path, "supported_formats": list(SUPPORTED_IMAGE_FORMATS)},
|
||||
)
|
||||
|
||||
file_size = os.path.getsize(image_path)
|
||||
if file_size > MAX_IMAGE_SIZE:
|
||||
log_and_raise_error(
|
||||
ImageNotFoundError,
|
||||
f"图片文件过大: {image_path}",
|
||||
{
|
||||
"path": image_path,
|
||||
"size": file_size,
|
||||
"max_size": f"{MAX_IMAGE_SIZE // (1024 * 1024)}MB",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with open(image_path, "rb") as img_file:
|
||||
image_data = img_file.read()
|
||||
except PermissionError:
|
||||
log_and_raise_error(
|
||||
ImageNotFoundError,
|
||||
f"没有权限读取图片文件: {image_path}",
|
||||
{"path": image_path, "error": "permission_denied"},
|
||||
)
|
||||
raise
|
||||
|
||||
processed = process_image_data(image_data)
|
||||
return base64.b64encode(processed).decode("utf-8")
|
||||
|
||||
|
||||
def encode_uploaded_image_as_base64(image_file) -> str:
|
||||
"""处理上传的图片(data URL / base64 字符串 / 文件对象)并返回 base64。"""
|
||||
if isinstance(image_file, str):
|
||||
if image_file.startswith("data:image"):
|
||||
_LOGGER.debug("处理 data URL 格式图片")
|
||||
try:
|
||||
_, encoded = image_file.split(",", 1)
|
||||
image_data = base64.b64decode(encoded)
|
||||
except ValueError:
|
||||
log_and_raise_error(
|
||||
ImageNotFoundError,
|
||||
"无效的 data URL 格式",
|
||||
{"data_url_length": len(image_file)},
|
||||
)
|
||||
raise
|
||||
processed = process_image_data(image_data)
|
||||
return base64.b64encode(processed).decode("utf-8")
|
||||
|
||||
# 视为 base64 字符串
|
||||
_LOGGER.debug("使用 base64 编码图片,长度: %d", len(image_file))
|
||||
try:
|
||||
image_data = base64.b64decode(image_file)
|
||||
except Exception as ex: # noqa: BLE001
|
||||
log_and_raise_error(
|
||||
ImageNotFoundError,
|
||||
"base64 解码失败",
|
||||
{"error": str(ex), "data_length": len(image_file)},
|
||||
)
|
||||
raise
|
||||
processed = process_image_data(image_data)
|
||||
return base64.b64encode(processed).decode("utf-8")
|
||||
|
||||
# 文件对象
|
||||
_LOGGER.debug("处理文件对象")
|
||||
image_data = image_file.read()
|
||||
processed = process_image_data(image_data)
|
||||
return base64.b64encode(processed).decode("utf-8")
|
||||
|
||||
|
||||
def get_image_base64(
|
||||
image_url: Optional[str] = None,
|
||||
image_path: Optional[str] = None,
|
||||
image_file=None,
|
||||
session: Optional[requests.Session] = None,
|
||||
) -> str:
|
||||
"""根据提供的来源获取图片 base64。
|
||||
|
||||
三者任选其一;优先级 url > path > file。
|
||||
"""
|
||||
if not any([image_url, image_path, image_file]):
|
||||
log_and_raise_error(
|
||||
ImageNotFoundError,
|
||||
"必须提供 image_url、image_path 或 image_file",
|
||||
{},
|
||||
)
|
||||
|
||||
if image_url:
|
||||
return download_image_as_base64(image_url, session=session)
|
||||
if image_path:
|
||||
return read_local_image_as_base64(image_path)
|
||||
return encode_uploaded_image_as_base64(image_file)
|
||||
|
||||
|
||||
class ImageCache:
|
||||
"""简单的 LRU 图片缓存,避免短时间内重复下载/读取同一图片。
|
||||
|
||||
下载使用的 ``requests.Session`` 通过 ``download_fn`` 注入,不参与缓存键。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
maxsize: int = 32,
|
||||
download_fn=None,
|
||||
read_fn=None,
|
||||
) -> None:
|
||||
self._download = lru_cache(maxsize=maxsize)(
|
||||
download_fn or download_image_as_base64
|
||||
)
|
||||
self._read_local = lru_cache(maxsize=maxsize)(
|
||||
read_fn or read_local_image_as_base64
|
||||
)
|
||||
|
||||
def get_url(self, image_url: str) -> str:
|
||||
return self._download(image_url)
|
||||
|
||||
def get_path(self, image_path: str) -> str:
|
||||
return self._read_local(image_path)
|
||||
|
||||
def get(self, image_url=None, image_path=None, image_file=None) -> str:
|
||||
if image_url:
|
||||
return self.get_url(image_url)
|
||||
if image_path:
|
||||
return self.get_path(image_path)
|
||||
if image_file:
|
||||
return encode_uploaded_image_as_base64(image_file)
|
||||
log_and_raise_error(
|
||||
ImageNotFoundError,
|
||||
"必须提供 image_url、image_path 或 image_file",
|
||||
{},
|
||||
)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._download.cache_clear()
|
||||
self._read_local.cache_clear()
|
||||
+3
-2
@@ -6,11 +6,12 @@
|
||||
"issue_tracker": "https://code.nextrt.com/Hass/tencent_face_recognition/issues",
|
||||
"requirements": [
|
||||
"tencentcloud-sdk-python-common>=3.0.0,<4.0.0",
|
||||
"tencentcloud-sdk-python-iai>=3.0.0,<4.0.0"
|
||||
"tencentcloud-sdk-python-iai>=3.0.0,<4.0.0",
|
||||
"Pillow>=10.0.0"
|
||||
],
|
||||
"dependencies": [],
|
||||
"codeowners": ["@xyzmos"],
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"iot_class": "cloud_polling",
|
||||
"resources": []
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
tencentcloud-sdk-python-common>=3.0.0,<4.0.0
|
||||
tencentcloud-sdk-python-iai>=3.0.0,<4.0.0
|
||||
Pillow>=10.0.0
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""重试与执行辅助模块。
|
||||
|
||||
将原 ``tencent_cloud_client.py`` 中的重试逻辑抽离出来,便于复用与测试。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from tencentcloud.common.exception.tencent_cloud_sdk_exception import (
|
||||
TencentCloudSDKException,
|
||||
)
|
||||
|
||||
from .errors import handle_api_error, log_and_raise_error, ApiError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# 重试默认参数
|
||||
MAX_ATTEMPTS = 3
|
||||
INITIAL_RETRY_DELAY = 1.0
|
||||
MAX_RETRY_DELAY = 30.0
|
||||
RETRY_BACKOFF_FACTOR = 2.0
|
||||
RETRY_JITTER = 0.1
|
||||
|
||||
# 不可重试的错误码(业务错误,重试无意义)
|
||||
NON_RETRYABLE_ERRORS = frozenset({
|
||||
"AuthFailure",
|
||||
"InvalidParameter",
|
||||
"InvalidParameterValue",
|
||||
"ResourceNotFound",
|
||||
"ResourceNotFoundInUse",
|
||||
"FailedOperation.PersonExist",
|
||||
"FailedOperation.PersonNotExist",
|
||||
"FailedOperation.FaceExist",
|
||||
"FailedOperation.FaceNotExist",
|
||||
"FailedOperation.GroupExist",
|
||||
"FailedOperation.GroupNotExist",
|
||||
})
|
||||
|
||||
# 速率限制类错误码(可重试)
|
||||
RATE_LIMIT_ERRORS = frozenset({
|
||||
"RequestLimitExceeded",
|
||||
"RequestQuotaExceeded",
|
||||
"LimitExceeded",
|
||||
})
|
||||
|
||||
# 网络/服务端临时错误码(可重试)
|
||||
NETWORK_ERRORS = frozenset({
|
||||
"ResourceUnavailable.NetworkError",
|
||||
"ResourceUnavailable.ServiceTimeout",
|
||||
"InternalError",
|
||||
"FailedOperation.RequestTimeout",
|
||||
})
|
||||
|
||||
|
||||
class RetryConfig:
|
||||
"""重试配置。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_attempts: int = MAX_ATTEMPTS,
|
||||
initial_delay: float = INITIAL_RETRY_DELAY,
|
||||
max_delay: float = MAX_RETRY_DELAY,
|
||||
backoff_factor: float = RETRY_BACKOFF_FACTOR,
|
||||
jitter: float = RETRY_JITTER,
|
||||
) -> None:
|
||||
self.max_attempts = max(max_attempts, 1)
|
||||
self.initial_delay = max(initial_delay, 0.0)
|
||||
self.max_delay = max(max_delay, self.initial_delay)
|
||||
self.backoff_factor = max(backoff_factor, 1.0)
|
||||
self.jitter = max(0.0, min(jitter, 1.0))
|
||||
|
||||
|
||||
def calculate_retry_delay(attempt: int, config: RetryConfig) -> float:
|
||||
"""计算第 ``attempt`` 次重试(从 1 开始)的延迟时间。"""
|
||||
delay = config.initial_delay * (config.backoff_factor ** (attempt - 1))
|
||||
delay = min(delay, config.max_delay)
|
||||
jitter = delay * config.jitter
|
||||
delay += random.uniform(-jitter, jitter)
|
||||
return max(0.0, delay)
|
||||
|
||||
|
||||
def should_retry(exception: TencentCloudSDKException) -> bool:
|
||||
"""判断 SDK 异常是否可重试。"""
|
||||
code = getattr(exception, "code", "") or ""
|
||||
|
||||
# 精确匹配不可重试
|
||||
if code in NON_RETRYABLE_ERRORS:
|
||||
return False
|
||||
|
||||
# 精确匹配可重试
|
||||
if code in RATE_LIMIT_ERRORS or code in NETWORK_ERRORS:
|
||||
return True
|
||||
|
||||
# 前缀匹配:AuthFailure.* / InvalidParameter.* / ResourceNotFound.* 不可重试
|
||||
for prefix in ("AuthFailure", "InvalidParameter", "ResourceNotFound"):
|
||||
if code.startswith(prefix):
|
||||
return False
|
||||
|
||||
# 其它未知错误默认可重试(服务端临时问题居多)
|
||||
return True
|
||||
|
||||
|
||||
def execute_with_retry(
|
||||
api_call: Callable[[], Any],
|
||||
operation_name: str,
|
||||
config: Optional[RetryConfig] = None,
|
||||
sanitize_error: Optional[Callable[[str], str]] = None,
|
||||
) -> Any:
|
||||
"""执行带重试的 API 调用。
|
||||
|
||||
``api_call`` 应返回业务结果字典(成功)或抛出 ``TencentCloudSDKException``
|
||||
/其它异常。当 ``api_call`` 内部已经把异常转化为 ``{success: False}`` 结果时,
|
||||
本函数不会重试——重试仅针对抛出的异常。
|
||||
|
||||
Args:
|
||||
api_call: 实际的 API 调用函数。
|
||||
operation_name: 操作名称,用于日志。
|
||||
config: 重试配置,默认 ``RetryConfig()``。
|
||||
sanitize_error: 可选的错误信息脱敏函数。
|
||||
"""
|
||||
cfg = config or RetryConfig()
|
||||
sanitize = sanitize_error or (lambda s: s)
|
||||
last_exception: Optional[Exception] = None
|
||||
|
||||
for attempt in range(cfg.max_attempts):
|
||||
try:
|
||||
if attempt > 0:
|
||||
delay = calculate_retry_delay(attempt, cfg)
|
||||
_LOGGER.info(
|
||||
"重试第 %d 次 %s,延迟 %.2f 秒", attempt, operation_name, delay
|
||||
)
|
||||
time.sleep(delay)
|
||||
return api_call()
|
||||
except TencentCloudSDKException as ex:
|
||||
last_exception = ex
|
||||
_LOGGER.warning(
|
||||
"%s 失败 (尝试 %d/%d): %s",
|
||||
operation_name, attempt + 1, cfg.max_attempts, ex.code,
|
||||
)
|
||||
if not should_retry(ex):
|
||||
_LOGGER.error("遇到不可重试的错误: %s", ex.code)
|
||||
break
|
||||
except Exception as ex: # noqa: BLE001 — 顶层兜底
|
||||
last_exception = ex
|
||||
_LOGGER.warning(
|
||||
"%s 失败 (尝试 %d/%d): %s",
|
||||
operation_name, attempt + 1, cfg.max_attempts, sanitize(str(ex)),
|
||||
)
|
||||
|
||||
# 所有尝试均失败
|
||||
if isinstance(last_exception, TencentCloudSDKException):
|
||||
handle_api_error(last_exception.code, last_exception.message)
|
||||
# handle_api_error 总会抛出,这里保险返回
|
||||
raise ApiError(f"{operation_name} 失败: {last_exception.code}")
|
||||
|
||||
if last_exception is not None:
|
||||
log_and_raise_error(
|
||||
ApiError,
|
||||
f"{operation_name} 失败: {last_exception}",
|
||||
{"error": str(last_exception), "error_type": type(last_exception).__name__},
|
||||
)
|
||||
|
||||
# 理论不可达
|
||||
raise ApiError(f"{operation_name} 失败: 未知错误")
|
||||
@@ -1,8 +1,18 @@
|
||||
"""传感器实体"""
|
||||
"""传感器实体。
|
||||
|
||||
提供两类传感器:
|
||||
- ``TencentFaceRecognitionStatusSensor``:诊断类状态传感器,反映连接状态与库信息。
|
||||
- ``TencentFaceRecognitionPersonSensor``:每个人员库成员一个传感器。
|
||||
|
||||
人员传感器基于 ``DataUpdateCoordinator`` 的数据动态创建/移除:当人员从
|
||||
人员库中删除后,对应传感器在下次刷新时被标记为不可用并移除。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -17,31 +27,40 @@ from homeassistant.helpers.update_coordinator import (
|
||||
)
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CONF_PERSON_GROUP_ID,
|
||||
DEFAULT_PERSON_GROUP_ID,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
SENSOR_PERSON,
|
||||
SENSOR_STATUS,
|
||||
get_entry_value,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER_POLLING = _LOGGER.getChild("polling")
|
||||
|
||||
# 性别枚举(与腾讯云 SDK 一致:0=女, 1=男)
|
||||
_GENDER_MAP = {0: "女", 1: "男", None: "未知"}
|
||||
|
||||
|
||||
class TencentFaceCoordinator(DataUpdateCoordinator):
|
||||
"""腾讯云人脸识别数据更新协调器"""
|
||||
"""腾讯云人脸识别数据更新协调器。"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, client):
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, client) -> None:
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER_POLLING,
|
||||
name="Tencent Face Recognition",
|
||||
update_interval=timedelta(minutes=5),
|
||||
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
||||
)
|
||||
self.client = client
|
||||
self.entry = entry
|
||||
# 动态传感器跟踪集合
|
||||
self._entity_person_ids: set[str] = set()
|
||||
self._person_sensors: dict[str, TencentFaceRecognitionPersonSensor] = {}
|
||||
|
||||
async def _async_update_data(self):
|
||||
"""获取最新数据"""
|
||||
async def _async_update_data(self) -> Dict[str, Any]:
|
||||
"""获取最新的人员库与成员数据。"""
|
||||
try:
|
||||
group_id = get_entry_value(
|
||||
self.entry, CONF_PERSON_GROUP_ID, DEFAULT_PERSON_GROUP_ID
|
||||
@@ -55,118 +74,166 @@ class TencentFaceCoordinator(DataUpdateCoordinator):
|
||||
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 []
|
||||
if not persons_result.get("success", False):
|
||||
raise UpdateFailed(
|
||||
f"获取人员列表失败: {persons_result.get('error_message', '未知错误')}"
|
||||
)
|
||||
persons = persons_result.get("persons", [])
|
||||
|
||||
return {
|
||||
"group_info": group_info,
|
||||
"persons": persons,
|
||||
"person_count": len(persons),
|
||||
"face_model_version": persons_result.get(
|
||||
"face_model_version",
|
||||
group_info.get("face_model_version", ""),
|
||||
),
|
||||
}
|
||||
except Exception as ex:
|
||||
_LOGGER_POLLING.debug("数据更新失败: %s", ex)
|
||||
except UpdateFailed:
|
||||
raise
|
||||
except Exception as ex: # noqa: BLE001
|
||||
_LOGGER_POLLING.warning("数据更新失败: %s", ex)
|
||||
raise UpdateFailed(f"数据更新失败: {ex}") from ex
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""设置传感器实体"""
|
||||
"""设置传感器实体。"""
|
||||
_LOGGER.debug("设置腾讯云人脸识别传感器实体")
|
||||
|
||||
client = hass.data[DOMAIN][entry.entry_id]["client"]
|
||||
name = entry.data.get(CONF_NAME, "腾讯云人脸识别")
|
||||
|
||||
coordinator = TencentFaceCoordinator(hass, entry, client)
|
||||
hass.data[DOMAIN][entry.entry_id]["coordinator"] = coordinator
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id]["coordinator"] = coordinator
|
||||
|
||||
entities = [
|
||||
entities: list[SensorEntity] = [
|
||||
TencentFaceRecognitionStatusSensor(coordinator, entry, name),
|
||||
]
|
||||
|
||||
for person in coordinator.data.get("persons", []):
|
||||
entities.append(
|
||||
TencentFaceRecognitionPersonSensor(coordinator, entry, person)
|
||||
)
|
||||
current_persons = {p["person_id"]: p for p in coordinator.data.get("persons", []) if p.get("person_id")}
|
||||
for person in current_persons.values():
|
||||
entities.append(TencentFaceRecognitionPersonSensor(coordinator, entry, person))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
# 注册协调器更新监听,动态增减人员传感器
|
||||
@callback
|
||||
def _async_update_person_sensors() -> None:
|
||||
"""根据协调器数据动态添加/移除人员传感器。"""
|
||||
if coordinator.data is None:
|
||||
return
|
||||
|
||||
new_persons = {
|
||||
p["person_id"]: p
|
||||
for p in coordinator.data.get("persons", [])
|
||||
if p.get("person_id")
|
||||
}
|
||||
|
||||
# 新增的人员 -> 创建传感器
|
||||
to_add = [
|
||||
TencentFaceRecognitionPersonSensor(coordinator, entry, person)
|
||||
for pid, person in new_persons.items()
|
||||
if pid not in coordinator._entity_person_ids
|
||||
]
|
||||
if to_add:
|
||||
async_add_entities(to_add)
|
||||
for sensor in to_add:
|
||||
coordinator._entity_person_ids.add(sensor._person_id)
|
||||
# _person_sensors 已在传感器 __init__ 中注册
|
||||
|
||||
# 已删除的人员 -> 移除对应传感器
|
||||
for pid in list(coordinator._entity_person_ids):
|
||||
if pid not in new_persons:
|
||||
sensor = coordinator._person_sensors.get(pid)
|
||||
if sensor is not None:
|
||||
sensor.async_remove()
|
||||
coordinator._entity_person_ids.discard(pid)
|
||||
coordinator._person_sensors.pop(pid, None)
|
||||
|
||||
# 补录初始已知人员 ID 集合(_person_sensors 已在传感器 __init__ 中填充)
|
||||
coordinator._entity_person_ids = set(current_persons.keys())
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_update_person_sensors))
|
||||
|
||||
|
||||
class TencentFaceRecognitionPersonSensor(CoordinatorEntity, SensorEntity):
|
||||
"""腾讯云人脸识别人员传感器"""
|
||||
"""腾讯云人脸识别人员传感器。
|
||||
|
||||
状态为人员名称;当人员从库中被删除后,传感器变为不可用并自动移除。
|
||||
"""
|
||||
|
||||
_attr_icon = "mdi:account"
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "person_sensor"
|
||||
|
||||
def __init__(self, coordinator, entry: ConfigEntry, person: Dict[str, Any]):
|
||||
"""初始化传感器"""
|
||||
def __init__(self, coordinator, entry: ConfigEntry, person: Dict[str, Any]) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._entry = entry
|
||||
self._person_id = person.get("person_id")
|
||||
person_name = person.get("person_name", "未知人员")
|
||||
self._attr_name = person_name
|
||||
self._attr_unique_id = f"{entry.entry_id}_{self._person_id}"
|
||||
self._attr_translation_key = "person_sensor"
|
||||
self._person_id: Optional[str] = person.get("person_id")
|
||||
self._attr_name = person.get("person_name", "未知人员")
|
||||
self._attr_unique_id = f"{entry.entry_id}_{SENSOR_PERSON}_{self._person_id}"
|
||||
# 注册到协调器的传感器映射,供动态删除使用
|
||||
coordinator._person_sensors[self._person_id] = self
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
"""返回传感器状态"""
|
||||
def _current_person(self) -> Optional[Dict[str, Any]]:
|
||||
if self.coordinator.data is None:
|
||||
return "未知"
|
||||
return None
|
||||
for person in self.coordinator.data.get("persons", []):
|
||||
if person.get("person_id") == self._person_id:
|
||||
return person.get("person_name", "未知")
|
||||
return "已删除"
|
||||
return person
|
||||
return None
|
||||
|
||||
@property
|
||||
def native_value(self) -> str:
|
||||
"""返回传感器状态(人员名称)。"""
|
||||
person = self._current_person()
|
||||
if person is None:
|
||||
return "已删除"
|
||||
return person.get("person_name", "未知")
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> Dict[str, Any]:
|
||||
"""返回传感器属性"""
|
||||
if self.coordinator.data is None:
|
||||
return {"person_id": self._person_id}
|
||||
|
||||
for person in self.coordinator.data.get("persons", []):
|
||||
if person.get("person_id") == self._person_id:
|
||||
return {
|
||||
"person_id": person.get("person_id"),
|
||||
"gender": "男" if person.get("gender") == 1 else "女",
|
||||
"face_ids": person.get("face_ids"),
|
||||
"face_count": len(person.get("face_ids", [])),
|
||||
}
|
||||
|
||||
return {"person_id": self._person_id, "status": "已删除"}
|
||||
"""返回传感器属性。"""
|
||||
person = self._current_person()
|
||||
if person is None:
|
||||
return {"person_id": self._person_id, "status": "已删除"}
|
||||
face_ids = person.get("face_ids", []) or []
|
||||
return {
|
||||
"person_id": person.get("person_id"),
|
||||
"gender": _GENDER_MAP.get(person.get("gender"), "未知"),
|
||||
"face_ids": face_ids,
|
||||
"face_count": len(face_ids),
|
||||
"creation_timestamp": person.get("creation_timestamp"),
|
||||
}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""传感器是否可用"""
|
||||
if self.coordinator.data is None:
|
||||
return False
|
||||
for person in self.coordinator.data.get("persons", []):
|
||||
if person.get("person_id") == self._person_id:
|
||||
return True
|
||||
return False
|
||||
"""人员存在时才可用。"""
|
||||
return self._current_person() is not None
|
||||
|
||||
|
||||
class TencentFaceRecognitionStatusSensor(CoordinatorEntity, SensorEntity):
|
||||
"""腾讯云人脸识别状态传感器"""
|
||||
"""腾讯云人脸识别状态传感器。"""
|
||||
|
||||
_attr_icon = "mdi:face-recognition"
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "status_sensor"
|
||||
|
||||
def __init__(self, coordinator, entry: ConfigEntry, name: str):
|
||||
"""初始化传感器"""
|
||||
def __init__(self, coordinator, entry: ConfigEntry, name: str) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._entry = entry
|
||||
self._name = name
|
||||
self._attr_name = f"{name} 状态"
|
||||
self._attr_unique_id = f"{entry.entry_id}_status"
|
||||
self._attr_unique_id = f"{entry.entry_id}_{SENSOR_STATUS}"
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
"""返回传感器状态"""
|
||||
def native_value(self) -> str:
|
||||
"""返回连接状态。"""
|
||||
if self.coordinator.data is None:
|
||||
return "未连接"
|
||||
if self.coordinator.last_update_success:
|
||||
@@ -175,30 +242,28 @@ class TencentFaceRecognitionStatusSensor(CoordinatorEntity, SensorEntity):
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> Dict[str, Any]:
|
||||
"""返回传感器属性"""
|
||||
attrs = {
|
||||
"last_updated": (
|
||||
self.coordinator.data.get("last_updated")
|
||||
if self.coordinator.data
|
||||
else None
|
||||
),
|
||||
"""返回状态传感器属性。"""
|
||||
data = self.coordinator.data or {}
|
||||
group_info = data.get("group_info", {}) if isinstance(data.get("group_info"), dict) else {}
|
||||
|
||||
attrs: Dict[str, Any] = {
|
||||
"api_status": "正常" if self.coordinator.last_update_success else "错误",
|
||||
"person_count": (
|
||||
self.coordinator.data.get("person_count", 0)
|
||||
if self.coordinator.data
|
||||
else 0
|
||||
),
|
||||
"last_error": None,
|
||||
"person_count": data.get("person_count", 0),
|
||||
"face_model_version": data.get("face_model_version", "")
|
||||
or group_info.get("face_model_version", ""),
|
||||
"last_exception": str(self.coordinator.last_exception) if self.coordinator.last_exception else None,
|
||||
"last_update_success": self.coordinator.last_update_success,
|
||||
}
|
||||
|
||||
if self.coordinator.data and self.coordinator.data.get("group_info"):
|
||||
group_info = self.coordinator.data["group_info"]
|
||||
if group_info:
|
||||
attrs["group_id"] = group_info.get("group_id", "")
|
||||
attrs["group_name"] = group_info.get("group_name", "")
|
||||
attrs["face_model_version"] = group_info.get("face_model_version", "")
|
||||
attrs["group_tag"] = group_info.get("group_tag", "")
|
||||
attrs["creation_timestamp"] = group_info.get("creation_timestamp", 0)
|
||||
|
||||
return attrs
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""状态传感器始终可用"""
|
||||
"""状态传感器始终可用。"""
|
||||
return True
|
||||
|
||||
+10
-8
@@ -8,6 +8,7 @@ import voluptuous as vol
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.service import SupportsResponse
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
@@ -125,7 +126,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_FACE_SEARCH,
|
||||
async_face_search_service,
|
||||
schema=FACE_SEARCH_SCHEMA,
|
||||
supports_response=True
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -133,7 +134,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_DETECT_FACE,
|
||||
async_detect_face_service,
|
||||
schema=DETECT_FACE_SCHEMA,
|
||||
supports_response=True
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -141,7 +142,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_GET_FACE_ATTRIBUTES,
|
||||
async_get_face_attributes_service,
|
||||
schema=GET_FACE_ATTRIBUTES_SCHEMA,
|
||||
supports_response=True
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -149,7 +150,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_CREATE_PERSON,
|
||||
async_create_person_service,
|
||||
schema=CREATE_PERSON_SCHEMA,
|
||||
supports_response=True
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -157,7 +158,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_DELETE_PERSON,
|
||||
async_delete_person_service,
|
||||
schema=DELETE_PERSON_SCHEMA,
|
||||
supports_response=True
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -165,7 +166,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_CREATE_FACE,
|
||||
async_create_face_service,
|
||||
schema=CREATE_FACE_SCHEMA,
|
||||
supports_response=True
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -173,7 +174,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_DELETE_FACE,
|
||||
async_delete_face_service,
|
||||
schema=DELETE_FACE_SCHEMA,
|
||||
supports_response=True
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
|
||||
|
||||
@@ -274,7 +275,8 @@ async def async_face_search_service(call: ServiceCall) -> Dict[str, Any]:
|
||||
ATTR_PERSON_ID: candidate.get("person_id"),
|
||||
ATTR_PERSON_NAME: candidate.get("person_name"),
|
||||
"score": candidate.get("score"),
|
||||
ATTR_PERSON_TAG: candidate.get("person_tag"),
|
||||
ATTR_FACE_ID: candidate.get("face_id"),
|
||||
"gender": candidate.get("gender"),
|
||||
ATTR_GROUP_ID: group_id,
|
||||
ATTR_CAMERA_ENTITY_ID: camera_entity_id,
|
||||
})
|
||||
|
||||
@@ -84,6 +84,74 @@ face_search:
|
||||
- label: "是"
|
||||
value: "1"
|
||||
|
||||
detect_face:
|
||||
name: 人脸检测
|
||||
description: 检测图片中的人脸位置和尺寸。
|
||||
fields:
|
||||
image_url:
|
||||
name: 图片URL
|
||||
description: 要检测的人脸图片的 URL。
|
||||
example: "https://example.com/image.jpg"
|
||||
selector:
|
||||
text:
|
||||
image_path:
|
||||
name: 图片路径
|
||||
description: 要检测的本地人脸图片路径。
|
||||
example: "/config/www/image.jpg"
|
||||
selector:
|
||||
text:
|
||||
image_file:
|
||||
name: 图片文件
|
||||
description: 要检测的人脸图片文件(Base64编码)。
|
||||
selector:
|
||||
text:
|
||||
camera_entity_id:
|
||||
name: 摄像头实体
|
||||
description: 用于获取图片的摄像头实体ID。
|
||||
example: "camera.front_door"
|
||||
selector:
|
||||
entity:
|
||||
domain: camera
|
||||
config_entry_id:
|
||||
name: 配置项ID
|
||||
description: 使用的配置项ID(多配置时指定)。
|
||||
selector:
|
||||
text:
|
||||
max_face_num:
|
||||
name: 最大人脸数量
|
||||
description: 最多检测的人脸数量。
|
||||
default: 1
|
||||
example: 1
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 10
|
||||
step: 1
|
||||
mode: box
|
||||
min_face_size:
|
||||
name: 最小人脸尺寸
|
||||
description: 最小人脸尺寸(像素)。
|
||||
default: 34
|
||||
example: 34
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 200
|
||||
step: 1
|
||||
mode: box
|
||||
need_rotate_check:
|
||||
name: 旋转检查
|
||||
description: 是否进行旋转检查(0=否,1=是)。
|
||||
default: 1
|
||||
example: 1
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- label: "否"
|
||||
value: "0"
|
||||
- label: "是"
|
||||
value: "1"
|
||||
|
||||
get_face_attributes:
|
||||
name: 获取人脸属性
|
||||
description: 获取图片中人脸的属性信息。
|
||||
|
||||
+233
-1196
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user