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
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Any, Dict
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
CONF_PERSON_GROUP_ID,
|
||||||
PLATFORMS,
|
CONF_REGION,
|
||||||
CONF_SECRET_ID,
|
CONF_SECRET_ID,
|
||||||
CONF_SECRET_KEY,
|
CONF_SECRET_KEY,
|
||||||
CONF_REGION,
|
DOMAIN,
|
||||||
CONF_PERSON_GROUP_ID,
|
PLATFORMS,
|
||||||
get_entry_value,
|
get_entry_value,
|
||||||
)
|
)
|
||||||
from .tencent_cloud_client import TencentCloudClient
|
from .errors import AuthenticationError, NetworkError
|
||||||
from .face_recognition import FaceRecognition
|
from .face_recognition import FaceRecognition
|
||||||
from .services import async_setup_services, async_unload_services
|
from .services import async_setup_services, async_unload_services
|
||||||
|
from .tencent_cloud_client import TencentCloudClient
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
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_id = entry.data.get(CONF_SECRET_ID)
|
||||||
secret_key = entry.data.get(CONF_SECRET_KEY)
|
secret_key = entry.data.get(CONF_SECRET_KEY)
|
||||||
region = get_entry_value(entry, CONF_REGION, "ap-shanghai")
|
region = get_entry_value(entry, CONF_REGION, "ap-shanghai")
|
||||||
person_group_id = get_entry_value(entry, CONF_PERSON_GROUP_ID, "Hass")
|
person_group_id = get_entry_value(entry, CONF_PERSON_GROUP_ID, "Hass")
|
||||||
|
|
||||||
|
# 1. 创建客户端
|
||||||
try:
|
try:
|
||||||
client = TencentCloudClient(secret_id, secret_key, region)
|
client = TencentCloudClient(secret_id, secret_key, region)
|
||||||
except Exception as ex:
|
except Exception as ex: # noqa: BLE001
|
||||||
_LOGGER.error("创建腾讯云客户端失败")
|
_LOGGER.error("创建腾讯云客户端失败: %s", ex)
|
||||||
raise ConfigEntryNotReady(f"创建腾讯云客户端失败: {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)
|
face_recognition = FaceRecognition(hass, client)
|
||||||
|
|
||||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
|
||||||
@@ -46,35 +62,59 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
"client": client,
|
"client": client,
|
||||||
"face_recognition": face_recognition,
|
"face_recognition": face_recognition,
|
||||||
"secret_id": secret_id,
|
"secret_id": secret_id,
|
||||||
"secret_key": secret_key,
|
|
||||||
"region": region,
|
"region": region,
|
||||||
"person_group_id": person_group_id
|
"person_group_id": person_group_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 4. 注册服务
|
||||||
await async_setup_services(hass)
|
await async_setup_services(hass)
|
||||||
|
|
||||||
|
# 5. 前向设置平台(sensor)
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
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
|
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:
|
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:
|
entry_data: Dict[str, Any] = hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
||||||
await async_unload_services(hass)
|
|
||||||
|
|
||||||
entry_data = hass.data[DOMAIN].pop(entry.entry_id, None)
|
if entry_data and "client" in entry_data:
|
||||||
if entry_data and "client" in entry_data:
|
client: TencentCloudClient = entry_data["client"]
|
||||||
client = entry_data["client"]
|
try:
|
||||||
await hass.async_add_executor_job(client.close)
|
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
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
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,
|
DEFAULT_REGION,
|
||||||
)
|
)
|
||||||
from .errors import (
|
from .errors import (
|
||||||
validate_config,
|
|
||||||
InvalidConfigError,
|
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
|
InvalidConfigError,
|
||||||
NetworkError,
|
NetworkError,
|
||||||
RateLimitError,
|
RateLimitError,
|
||||||
log_and_raise_error
|
validate_config,
|
||||||
)
|
)
|
||||||
from .tencent_cloud_client import TencentCloudClient
|
from .tencent_cloud_client import TencentCloudClient
|
||||||
|
|
||||||
@@ -36,7 +35,9 @@ class TencentFaceRecognitionConfigFlow(config_entries.ConfigFlow, domain=DOMAIN)
|
|||||||
"""腾讯云人脸识别配置流程"""
|
"""腾讯云人脸识别配置流程"""
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
|
|
||||||
|
# CONNECTION_CLASS 已在 HA 2024.7 废弃,连接类型现由 manifest.json 的
|
||||||
|
# iot_class 字段声明(本项目为 cloud_polling)。
|
||||||
|
|
||||||
AVAILABLE_REGIONS = [
|
AVAILABLE_REGIONS = [
|
||||||
"ap-beijing", "ap-shanghai", "ap-guangzhou", "ap-chengdu",
|
"ap-beijing", "ap-shanghai", "ap-guangzhou", "ap-chengdu",
|
||||||
@@ -65,6 +66,12 @@ class TencentFaceRecognitionConfigFlow(config_entries.ConfigFlow, domain=DOMAIN)
|
|||||||
|
|
||||||
validate_config(user_input)
|
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)
|
validation_result = await self._validate_tencent_cloud_credentials(user_input)
|
||||||
|
|
||||||
if not validation_result["success"]:
|
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]:
|
async def _validate_tencent_cloud_credentials(self, user_input: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""验证腾讯云凭据 - 通过真实API调用验证"""
|
"""验证腾讯云凭据 - 通过真实API调用验证"""
|
||||||
|
client = None
|
||||||
try:
|
try:
|
||||||
_LOGGER.info("开始验证腾讯云凭据")
|
_LOGGER.info("开始验证腾讯云凭据")
|
||||||
|
|
||||||
@@ -186,6 +194,9 @@ class TencentFaceRecognitionConfigFlow(config_entries.ConfigFlow, domain=DOMAIN)
|
|||||||
"base": f"腾讯云API验证失败: {str(ex)}"
|
"base": f"腾讯云API验证失败: {str(ex)}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finally:
|
||||||
|
if client is not None:
|
||||||
|
await self.hass.async_add_executor_job(client.close)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
@@ -252,6 +263,7 @@ class TencentFaceRecognitionOptionsFlow(config_entries.OptionsFlow):
|
|||||||
errors[CONF_PERSON_GROUP_ID] = "人员库ID只能包含字母、数字、连字符和下划线,长度1-64个字符"
|
errors[CONF_PERSON_GROUP_ID] = "人员库ID只能包含字母、数字、连字符和下划线,长度1-64个字符"
|
||||||
|
|
||||||
if not errors:
|
if not errors:
|
||||||
|
client = None
|
||||||
try:
|
try:
|
||||||
client = TencentCloudClient(
|
client = TencentCloudClient(
|
||||||
self._data.get(CONF_SECRET_ID),
|
self._data.get(CONF_SECRET_ID),
|
||||||
@@ -287,6 +299,9 @@ class TencentFaceRecognitionOptionsFlow(config_entries.OptionsFlow):
|
|||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
_LOGGER.error("配置验证失败: %s", ex)
|
_LOGGER.error("配置验证失败: %s", ex)
|
||||||
errors["base"] = f"配置验证失败: {str(ex)}"
|
errors["base"] = f"配置验证失败: {str(ex)}"
|
||||||
|
finally:
|
||||||
|
if client is not None:
|
||||||
|
await self.hass.async_add_executor_job(client.close)
|
||||||
|
|
||||||
options = {
|
options = {
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
@@ -316,6 +331,7 @@ class TencentFaceRecognitionOptionsFlow(config_entries.OptionsFlow):
|
|||||||
connection_status = "未知"
|
connection_status = "未知"
|
||||||
|
|
||||||
if user_input is None:
|
if user_input is None:
|
||||||
|
client = None
|
||||||
try:
|
try:
|
||||||
client = TencentCloudClient(
|
client = TencentCloudClient(
|
||||||
self._data.get(CONF_SECRET_ID),
|
self._data.get(CONF_SECRET_ID),
|
||||||
@@ -324,7 +340,7 @@ class TencentFaceRecognitionOptionsFlow(config_entries.OptionsFlow):
|
|||||||
)
|
)
|
||||||
|
|
||||||
success = await self.hass.async_add_executor_job(
|
success = await self.hass.async_add_executor_job(
|
||||||
client._test_api_connection,
|
client.test_api_connection,
|
||||||
self._data.get(CONF_PERSON_GROUP_ID, "Hass")
|
self._data.get(CONF_PERSON_GROUP_ID, "Hass")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -346,6 +362,9 @@ class TencentFaceRecognitionOptionsFlow(config_entries.OptionsFlow):
|
|||||||
connection_status = f"测试失败: {str(ex)}"
|
connection_status = f"测试失败: {str(ex)}"
|
||||||
errors["base"] = f"连接测试失败: {str(ex)}"
|
errors["base"] = f"连接测试失败: {str(ex)}"
|
||||||
_LOGGER.error("连接测试失败: %s", 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(
|
return self.async_show_form(
|
||||||
step_id="test_connection",
|
step_id="test_connection",
|
||||||
@@ -361,21 +380,22 @@ class TencentFaceRecognitionOptionsFlow(config_entries.OptionsFlow):
|
|||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
if user_input is not None:
|
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:
|
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(
|
new_client = TencentCloudClient(
|
||||||
user_input[CONF_SECRET_ID],
|
user_input[CONF_SECRET_ID],
|
||||||
user_input[CONF_SECRET_KEY],
|
user_input[CONF_SECRET_KEY],
|
||||||
@@ -405,6 +425,9 @@ class TencentFaceRecognitionOptionsFlow(config_entries.OptionsFlow):
|
|||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
_LOGGER.error("重新认证失败: %s", ex)
|
_LOGGER.error("重新认证失败: %s", ex)
|
||||||
errors["base"] = f"重新认证失败: {str(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(
|
return self.async_show_form(
|
||||||
step_id="reauth",
|
step_id="reauth",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ CONF_SECRET_ID = "secret_id"
|
|||||||
CONF_SECRET_KEY = "secret_key"
|
CONF_SECRET_KEY = "secret_key"
|
||||||
CONF_REGION = "region"
|
CONF_REGION = "region"
|
||||||
CONF_PERSON_GROUP_ID = "person_group_id"
|
CONF_PERSON_GROUP_ID = "person_group_id"
|
||||||
|
CONF_SCAN_INTERVAL = "scan_interval"
|
||||||
|
|
||||||
DEFAULT_REGION = "ap-shanghai"
|
DEFAULT_REGION = "ap-shanghai"
|
||||||
DEFAULT_PERSON_GROUP_ID = "Hass"
|
DEFAULT_PERSON_GROUP_ID = "Hass"
|
||||||
@@ -16,6 +17,7 @@ DEFAULT_MAX_USER_NUM = 5
|
|||||||
DEFAULT_QUALITY_CONTROL = 1
|
DEFAULT_QUALITY_CONTROL = 1
|
||||||
DEFAULT_NEED_ROTATE_CHECK = 1
|
DEFAULT_NEED_ROTATE_CHECK = 1
|
||||||
DEFAULT_FACE_MATCH_THRESHOLD = 60.0
|
DEFAULT_FACE_MATCH_THRESHOLD = 60.0
|
||||||
|
DEFAULT_SCAN_INTERVAL = 300 # seconds (5 minutes)
|
||||||
|
|
||||||
SERVICE_FACE_SEARCH = "face_search"
|
SERVICE_FACE_SEARCH = "face_search"
|
||||||
SERVICE_DETECT_FACE = "detect_face"
|
SERVICE_DETECT_FACE = "detect_face"
|
||||||
@@ -45,6 +47,10 @@ ATTR_PERSON_TAG = "person_tag"
|
|||||||
|
|
||||||
EVENT_FACE_DETECTED = "face_detected"
|
EVENT_FACE_DETECTED = "face_detected"
|
||||||
|
|
||||||
|
# Sensor identifiers
|
||||||
|
SENSOR_STATUS = "status"
|
||||||
|
SENSOR_PERSON = "person"
|
||||||
|
|
||||||
ERROR_INVALID_CONFIG = "无效的配置"
|
ERROR_INVALID_CONFIG = "无效的配置"
|
||||||
ERROR_API_ERROR = "API调用错误"
|
ERROR_API_ERROR = "API调用错误"
|
||||||
ERROR_IMAGE_NOT_FOUND = "图片未找到"
|
ERROR_IMAGE_NOT_FOUND = "图片未找到"
|
||||||
|
|||||||
@@ -258,22 +258,51 @@ class QuotaExceededError(TencentFaceRecognitionError):
|
|||||||
|
|
||||||
|
|
||||||
def handle_api_error(error_code: str, error_message: str) -> None:
|
def handle_api_error(error_code: str, error_message: str) -> None:
|
||||||
"""处理API错误"""
|
"""处理API错误。
|
||||||
|
|
||||||
|
先尝试精确匹配,再用前缀匹配,最后回退到通用 ``ApiError``。
|
||||||
|
"""
|
||||||
error_mapping = {
|
error_mapping = {
|
||||||
"FailedOperation.ImageDecodeFailed": (ImageNotFoundError, "image_decode_failed"),
|
"FailedOperation.ImageDecodeFailed": (ImageNotFoundError, "image_decode_failed"),
|
||||||
|
"FailedOperation.ImageSizeTooLarge": (ImageNotFoundError, "image_too_large"),
|
||||||
"InvalidParameterValue.NoFaceInPhoto": (FaceNotDetectedError, "face_not_detected"),
|
"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.PersonNotFound": (PersonNotFoundError, "person_not_found"),
|
||||||
"FailedOperation.GroupNotFound": (PersonGroupNotFoundError, "group_not_found"),
|
"FailedOperation.GroupNotFound": (PersonGroupNotFoundError, "group_not_found"),
|
||||||
"FailedOperation.FaceNotFound": (FaceNotFoundError, "face_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"),
|
"RequestLimitExceeded": (RateLimitError, "rate_limit_exceeded"),
|
||||||
"RequestQuotaExceeded": (QuotaExceededError, "quota_exceeded"),
|
"RequestQuotaExceeded": (QuotaExceededError, "quota_exceeded"),
|
||||||
"ResourceUnavailable.NetworkError": (NetworkError, "network_error"),
|
"ResourceUnavailable.NetworkError": (NetworkError, "network_error"),
|
||||||
}
|
}
|
||||||
|
|
||||||
error_info = error_mapping.get(error_code, (ApiError, "api_error"))
|
error_class = None
|
||||||
error_class = error_info[0]
|
error_key = None
|
||||||
error_key = error_info[1]
|
|
||||||
|
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)
|
_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:
|
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(
|
``error_class`` 必须是 ``TencentFaceRecognitionError``(或 ``HomeAssistantError``)
|
||||||
message,
|
的子类,以保证上层 ``except HomeAssistantError`` 能正确捕获。
|
||||||
error_key=error_key,
|
如果传入的 ``error_class`` 无法构造为带 ``error_key``/``error_details`` 的自定义错误
|
||||||
error_details=details or {}
|
(例如 ``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:
|
def validate_config(config: Dict[str, Any]) -> None:
|
||||||
|
|||||||
+6
-3
@@ -53,7 +53,6 @@ class FaceRecognition:
|
|||||||
image_file,
|
image_file,
|
||||||
)
|
)
|
||||||
raise ValueError("必须提供image_url、image_path、image_file或camera_entity_id")
|
raise ValueError("必须提供image_url、image_path、image_file或camera_entity_id")
|
||||||
|
|
||||||
async def _get_camera_image_base64(self, camera_entity_id: str) -> str:
|
async def _get_camera_image_base64(self, camera_entity_id: str) -> str:
|
||||||
try:
|
try:
|
||||||
from homeassistant.components.camera import async_get_image
|
from homeassistant.components.camera import async_get_image
|
||||||
@@ -151,11 +150,13 @@ class FaceRecognition:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def _process_search_results(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
def _process_search_results(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
"""规范化搜索结果字段(保留客户端返回的全部信息)。"""
|
||||||
processed_results = []
|
processed_results = []
|
||||||
for result in results:
|
for result in results:
|
||||||
processed_result = {
|
processed_result = {
|
||||||
"face_id": result.get("face_id"),
|
"face_id": result.get("face_id"),
|
||||||
"candidates": []
|
"face_rect": result.get("face_rect"),
|
||||||
|
"candidates": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
for candidate in result.get("candidates", []):
|
for candidate in result.get("candidates", []):
|
||||||
@@ -163,7 +164,9 @@ class FaceRecognition:
|
|||||||
"person_id": candidate.get("person_id"),
|
"person_id": candidate.get("person_id"),
|
||||||
"person_name": candidate.get("person_name"),
|
"person_name": candidate.get("person_name"),
|
||||||
"score": candidate.get("score"),
|
"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)
|
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",
|
"issue_tracker": "https://code.nextrt.com/Hass/tencent_face_recognition/issues",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"tencentcloud-sdk-python-common>=3.0.0,<4.0.0",
|
"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": [],
|
"dependencies": [],
|
||||||
"codeowners": ["@xyzmos"],
|
"codeowners": ["@xyzmos"],
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"resources": []
|
"resources": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
tencentcloud-sdk-python-common>=3.0.0,<4.0.0
|
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
|
||||||
|
|||||||
@@ -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
|
import logging
|
||||||
from typing import Dict, Any, Optional, List
|
from datetime import timedelta
|
||||||
from datetime import datetime, timedelta
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from homeassistant.components.sensor import SensorEntity
|
from homeassistant.components.sensor import SensorEntity
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
@@ -17,31 +27,40 @@ from homeassistant.helpers.update_coordinator import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
|
||||||
CONF_PERSON_GROUP_ID,
|
CONF_PERSON_GROUP_ID,
|
||||||
DEFAULT_PERSON_GROUP_ID,
|
DEFAULT_PERSON_GROUP_ID,
|
||||||
|
DEFAULT_SCAN_INTERVAL,
|
||||||
|
DOMAIN,
|
||||||
|
SENSOR_PERSON,
|
||||||
|
SENSOR_STATUS,
|
||||||
get_entry_value,
|
get_entry_value,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
_LOGGER_POLLING = _LOGGER.getChild("polling")
|
_LOGGER_POLLING = _LOGGER.getChild("polling")
|
||||||
|
|
||||||
|
# 性别枚举(与腾讯云 SDK 一致:0=女, 1=男)
|
||||||
|
_GENDER_MAP = {0: "女", 1: "男", None: "未知"}
|
||||||
|
|
||||||
|
|
||||||
class TencentFaceCoordinator(DataUpdateCoordinator):
|
class TencentFaceCoordinator(DataUpdateCoordinator):
|
||||||
"""腾讯云人脸识别数据更新协调器"""
|
"""腾讯云人脸识别数据更新协调器。"""
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, client):
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, client) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
_LOGGER_POLLING,
|
_LOGGER_POLLING,
|
||||||
name="Tencent Face Recognition",
|
name="Tencent Face Recognition",
|
||||||
update_interval=timedelta(minutes=5),
|
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
||||||
)
|
)
|
||||||
self.client = client
|
self.client = client
|
||||||
self.entry = entry
|
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:
|
try:
|
||||||
group_id = get_entry_value(
|
group_id = get_entry_value(
|
||||||
self.entry, CONF_PERSON_GROUP_ID, DEFAULT_PERSON_GROUP_ID
|
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(
|
persons_result = await self.hass.async_add_executor_job(
|
||||||
self.client.get_person_list_all, group_id
|
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 {
|
return {
|
||||||
"group_info": group_info,
|
"group_info": group_info,
|
||||||
"persons": persons,
|
"persons": persons,
|
||||||
"person_count": len(persons),
|
"person_count": len(persons),
|
||||||
|
"face_model_version": persons_result.get(
|
||||||
|
"face_model_version",
|
||||||
|
group_info.get("face_model_version", ""),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
except Exception as ex:
|
except UpdateFailed:
|
||||||
_LOGGER_POLLING.debug("数据更新失败: %s", ex)
|
raise
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
_LOGGER_POLLING.warning("数据更新失败: %s", ex)
|
||||||
raise UpdateFailed(f"数据更新失败: {ex}") from ex
|
raise UpdateFailed(f"数据更新失败: {ex}") from ex
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
) -> None:
|
) -> None:
|
||||||
"""设置传感器实体"""
|
"""设置传感器实体。"""
|
||||||
_LOGGER.debug("设置腾讯云人脸识别传感器实体")
|
_LOGGER.debug("设置腾讯云人脸识别传感器实体")
|
||||||
|
|
||||||
client = hass.data[DOMAIN][entry.entry_id]["client"]
|
client = hass.data[DOMAIN][entry.entry_id]["client"]
|
||||||
name = entry.data.get(CONF_NAME, "腾讯云人脸识别")
|
name = entry.data.get(CONF_NAME, "腾讯云人脸识别")
|
||||||
|
|
||||||
coordinator = TencentFaceCoordinator(hass, entry, client)
|
coordinator = TencentFaceCoordinator(hass, entry, client)
|
||||||
|
hass.data[DOMAIN][entry.entry_id]["coordinator"] = coordinator
|
||||||
|
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
hass.data[DOMAIN][entry.entry_id]["coordinator"] = coordinator
|
entities: list[SensorEntity] = [
|
||||||
|
|
||||||
entities = [
|
|
||||||
TencentFaceRecognitionStatusSensor(coordinator, entry, name),
|
TencentFaceRecognitionStatusSensor(coordinator, entry, name),
|
||||||
]
|
]
|
||||||
|
|
||||||
for person in coordinator.data.get("persons", []):
|
current_persons = {p["person_id"]: p for p in coordinator.data.get("persons", []) if p.get("person_id")}
|
||||||
entities.append(
|
for person in current_persons.values():
|
||||||
TencentFaceRecognitionPersonSensor(coordinator, entry, person)
|
entities.append(TencentFaceRecognitionPersonSensor(coordinator, entry, person))
|
||||||
)
|
|
||||||
|
|
||||||
async_add_entities(entities)
|
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):
|
class TencentFaceRecognitionPersonSensor(CoordinatorEntity, SensorEntity):
|
||||||
"""腾讯云人脸识别人员传感器"""
|
"""腾讯云人脸识别人员传感器。
|
||||||
|
|
||||||
|
状态为人员名称;当人员从库中被删除后,传感器变为不可用并自动移除。
|
||||||
|
"""
|
||||||
|
|
||||||
_attr_icon = "mdi:account"
|
_attr_icon = "mdi:account"
|
||||||
_attr_has_entity_name = True
|
_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)
|
super().__init__(coordinator)
|
||||||
self._entry = entry
|
self._entry = entry
|
||||||
self._person_id = person.get("person_id")
|
self._person_id: Optional[str] = person.get("person_id")
|
||||||
person_name = person.get("person_name", "未知人员")
|
self._attr_name = person.get("person_name", "未知人员")
|
||||||
self._attr_name = person_name
|
self._attr_unique_id = f"{entry.entry_id}_{SENSOR_PERSON}_{self._person_id}"
|
||||||
self._attr_unique_id = f"{entry.entry_id}_{self._person_id}"
|
# 注册到协调器的传感器映射,供动态删除使用
|
||||||
self._attr_translation_key = "person_sensor"
|
coordinator._person_sensors[self._person_id] = self
|
||||||
|
|
||||||
@property
|
def _current_person(self) -> Optional[Dict[str, Any]]:
|
||||||
def state(self) -> str:
|
|
||||||
"""返回传感器状态"""
|
|
||||||
if self.coordinator.data is None:
|
if self.coordinator.data is None:
|
||||||
return "未知"
|
return None
|
||||||
for person in self.coordinator.data.get("persons", []):
|
for person in self.coordinator.data.get("persons", []):
|
||||||
if person.get("person_id") == self._person_id:
|
if person.get("person_id") == self._person_id:
|
||||||
return person.get("person_name", "未知")
|
return person
|
||||||
return "已删除"
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> str:
|
||||||
|
"""返回传感器状态(人员名称)。"""
|
||||||
|
person = self._current_person()
|
||||||
|
if person is None:
|
||||||
|
return "已删除"
|
||||||
|
return person.get("person_name", "未知")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> Dict[str, Any]:
|
def extra_state_attributes(self) -> Dict[str, Any]:
|
||||||
"""返回传感器属性"""
|
"""返回传感器属性。"""
|
||||||
if self.coordinator.data is None:
|
person = self._current_person()
|
||||||
return {"person_id": self._person_id}
|
if person is None:
|
||||||
|
return {"person_id": self._person_id, "status": "已删除"}
|
||||||
for person in self.coordinator.data.get("persons", []):
|
face_ids = person.get("face_ids", []) or []
|
||||||
if person.get("person_id") == self._person_id:
|
return {
|
||||||
return {
|
"person_id": person.get("person_id"),
|
||||||
"person_id": person.get("person_id"),
|
"gender": _GENDER_MAP.get(person.get("gender"), "未知"),
|
||||||
"gender": "男" if person.get("gender") == 1 else "女",
|
"face_ids": face_ids,
|
||||||
"face_ids": person.get("face_ids"),
|
"face_count": len(face_ids),
|
||||||
"face_count": len(person.get("face_ids", [])),
|
"creation_timestamp": person.get("creation_timestamp"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {"person_id": self._person_id, "status": "已删除"}
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""传感器是否可用"""
|
"""人员存在时才可用。"""
|
||||||
if self.coordinator.data is None:
|
return self._current_person() is not None
|
||||||
return False
|
|
||||||
for person in self.coordinator.data.get("persons", []):
|
|
||||||
if person.get("person_id") == self._person_id:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class TencentFaceRecognitionStatusSensor(CoordinatorEntity, SensorEntity):
|
class TencentFaceRecognitionStatusSensor(CoordinatorEntity, SensorEntity):
|
||||||
"""腾讯云人脸识别状态传感器"""
|
"""腾讯云人脸识别状态传感器。"""
|
||||||
|
|
||||||
_attr_icon = "mdi:face-recognition"
|
_attr_icon = "mdi:face-recognition"
|
||||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_translation_key = "status_sensor"
|
_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)
|
super().__init__(coordinator)
|
||||||
self._entry = entry
|
self._entry = entry
|
||||||
self._name = name
|
self._name = name
|
||||||
self._attr_name = f"{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
|
@property
|
||||||
def state(self) -> str:
|
def native_value(self) -> str:
|
||||||
"""返回传感器状态"""
|
"""返回连接状态。"""
|
||||||
if self.coordinator.data is None:
|
if self.coordinator.data is None:
|
||||||
return "未连接"
|
return "未连接"
|
||||||
if self.coordinator.last_update_success:
|
if self.coordinator.last_update_success:
|
||||||
@@ -175,30 +242,28 @@ class TencentFaceRecognitionStatusSensor(CoordinatorEntity, SensorEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> Dict[str, Any]:
|
def extra_state_attributes(self) -> Dict[str, Any]:
|
||||||
"""返回传感器属性"""
|
"""返回状态传感器属性。"""
|
||||||
attrs = {
|
data = self.coordinator.data or {}
|
||||||
"last_updated": (
|
group_info = data.get("group_info", {}) if isinstance(data.get("group_info"), dict) else {}
|
||||||
self.coordinator.data.get("last_updated")
|
|
||||||
if self.coordinator.data
|
attrs: Dict[str, Any] = {
|
||||||
else None
|
|
||||||
),
|
|
||||||
"api_status": "正常" if self.coordinator.last_update_success else "错误",
|
"api_status": "正常" if self.coordinator.last_update_success else "错误",
|
||||||
"person_count": (
|
"person_count": data.get("person_count", 0),
|
||||||
self.coordinator.data.get("person_count", 0)
|
"face_model_version": data.get("face_model_version", "")
|
||||||
if self.coordinator.data
|
or group_info.get("face_model_version", ""),
|
||||||
else 0
|
"last_exception": str(self.coordinator.last_exception) if self.coordinator.last_exception else None,
|
||||||
),
|
"last_update_success": self.coordinator.last_update_success,
|
||||||
"last_error": None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.coordinator.data and self.coordinator.data.get("group_info"):
|
if group_info:
|
||||||
group_info = self.coordinator.data["group_info"]
|
attrs["group_id"] = group_info.get("group_id", "")
|
||||||
attrs["group_name"] = group_info.get("group_name", "")
|
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
|
return attrs
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""状态传感器始终可用"""
|
"""状态传感器始终可用。"""
|
||||||
return True
|
return True
|
||||||
|
|||||||
+10
-8
@@ -8,6 +8,7 @@ import voluptuous as vol
|
|||||||
from homeassistant.core import HomeAssistant, ServiceCall
|
from homeassistant.core import HomeAssistant, ServiceCall
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from homeassistant.helpers.service import SupportsResponse
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -125,7 +126,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
|||||||
SERVICE_FACE_SEARCH,
|
SERVICE_FACE_SEARCH,
|
||||||
async_face_search_service,
|
async_face_search_service,
|
||||||
schema=FACE_SEARCH_SCHEMA,
|
schema=FACE_SEARCH_SCHEMA,
|
||||||
supports_response=True
|
supports_response=SupportsResponse.OPTIONAL,
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
@@ -133,7 +134,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
|||||||
SERVICE_DETECT_FACE,
|
SERVICE_DETECT_FACE,
|
||||||
async_detect_face_service,
|
async_detect_face_service,
|
||||||
schema=DETECT_FACE_SCHEMA,
|
schema=DETECT_FACE_SCHEMA,
|
||||||
supports_response=True
|
supports_response=SupportsResponse.OPTIONAL,
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
@@ -141,7 +142,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
|||||||
SERVICE_GET_FACE_ATTRIBUTES,
|
SERVICE_GET_FACE_ATTRIBUTES,
|
||||||
async_get_face_attributes_service,
|
async_get_face_attributes_service,
|
||||||
schema=GET_FACE_ATTRIBUTES_SCHEMA,
|
schema=GET_FACE_ATTRIBUTES_SCHEMA,
|
||||||
supports_response=True
|
supports_response=SupportsResponse.OPTIONAL,
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
@@ -149,7 +150,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
|||||||
SERVICE_CREATE_PERSON,
|
SERVICE_CREATE_PERSON,
|
||||||
async_create_person_service,
|
async_create_person_service,
|
||||||
schema=CREATE_PERSON_SCHEMA,
|
schema=CREATE_PERSON_SCHEMA,
|
||||||
supports_response=True
|
supports_response=SupportsResponse.OPTIONAL,
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
@@ -157,7 +158,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
|||||||
SERVICE_DELETE_PERSON,
|
SERVICE_DELETE_PERSON,
|
||||||
async_delete_person_service,
|
async_delete_person_service,
|
||||||
schema=DELETE_PERSON_SCHEMA,
|
schema=DELETE_PERSON_SCHEMA,
|
||||||
supports_response=True
|
supports_response=SupportsResponse.OPTIONAL,
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
@@ -165,7 +166,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
|||||||
SERVICE_CREATE_FACE,
|
SERVICE_CREATE_FACE,
|
||||||
async_create_face_service,
|
async_create_face_service,
|
||||||
schema=CREATE_FACE_SCHEMA,
|
schema=CREATE_FACE_SCHEMA,
|
||||||
supports_response=True
|
supports_response=SupportsResponse.OPTIONAL,
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
@@ -173,7 +174,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
|||||||
SERVICE_DELETE_FACE,
|
SERVICE_DELETE_FACE,
|
||||||
async_delete_face_service,
|
async_delete_face_service,
|
||||||
schema=DELETE_FACE_SCHEMA,
|
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_ID: candidate.get("person_id"),
|
||||||
ATTR_PERSON_NAME: candidate.get("person_name"),
|
ATTR_PERSON_NAME: candidate.get("person_name"),
|
||||||
"score": candidate.get("score"),
|
"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_GROUP_ID: group_id,
|
||||||
ATTR_CAMERA_ENTITY_ID: camera_entity_id,
|
ATTR_CAMERA_ENTITY_ID: camera_entity_id,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -84,6 +84,74 @@ face_search:
|
|||||||
- label: "是"
|
- label: "是"
|
||||||
value: "1"
|
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:
|
get_face_attributes:
|
||||||
name: 获取人脸属性
|
name: 获取人脸属性
|
||||||
description: 获取图片中人脸的属性信息。
|
description: 获取图片中人脸的属性信息。
|
||||||
|
|||||||
+233
-1196
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user