Files
Hermes 2f19148ff1 feat: 添加 HA 管理面板功能
- config_flow.py: 新增人员管理步骤(async_step_manage/list/create)
  支持在 HA 配置 -> 选项 中查看人员列表、创建和删除
- __init__.py: 新增 3 个 WebSocket API 命令(list/create/delete)
  前端可通过 HA WebSocket 直接调用
- blueprints/face_detection_automation.yaml: 自动化蓝图
  支持人脸检测/搜索 + 通知推送
- manifest.json: 版本号 2.2.0

用户现可通过 HA 界面对人员进行可视化管理
2026-07-07 07:27:19 +08:00

584 lines
23 KiB
Python

"""腾讯云人脸识别插件配置流程"""
import logging
import re
from typing import Dict, Any, Optional, List
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.const import CONF_NAME
from homeassistant.helpers.selector import selector
from .const import (
DOMAIN,
CONF_SECRET_ID,
CONF_SECRET_KEY,
CONF_REGION,
CONF_PERSON_GROUP_ID,
DEFAULT_REGION,
)
from .errors import (
AuthenticationError,
InvalidConfigError,
NetworkError,
RateLimitError,
validate_config,
)
from .tencent_cloud_client import TencentCloudClient
_LOGGER = logging.getLogger(__name__)
class TencentFaceRecognitionConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""腾讯云人脸识别配置流程"""
VERSION = 1
# CONNECTION_CLASS 已在 HA 2024.7 废弃,连接类型现由 manifest.json 的
# iot_class 字段声明(本项目为 cloud_polling)。
AVAILABLE_REGIONS = [
"ap-beijing", "ap-shanghai", "ap-guangzhou", "ap-chengdu",
"ap-chongqing", "ap-nanjing", "ap-hangzhou"
]
SECRET_ID_REGEX = re.compile(r'^AKID[a-zA-Z0-9\-]{36,}$')
SECRET_KEY_REGEX = re.compile(r'^[a-zA-Z0-9+/]{20,}$')
PERSON_GROUP_ID_REGEX = re.compile(r'^[a-zA-Z0-9\-_]{1,64}$')
def __init__(self):
"""初始化配置流程"""
self._client = None
self._config_info = {}
async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None):
"""处理用户配置步骤"""
errors = {}
if user_input is not None:
try:
self._validate_input_format(user_input, errors)
if errors:
return self._show_user_form(errors)
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"]:
errors = validation_result["errors"]
return self._show_user_form(errors)
return self.async_create_entry(
title=user_input.get(CONF_NAME, "腾讯云人脸识别"),
data=user_input
)
except InvalidConfigError as ex:
_LOGGER.error("配置验证失败: %s", ex)
errors["base"] = str(ex)
except Exception as ex:
_LOGGER.error("配置验证失败: %s", ex)
errors["base"] = f"配置验证失败: {str(ex)}"
return self._show_user_form(errors)
def _show_user_form(self, errors: Dict[str, str]) -> dict:
"""显示用户配置表单"""
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required(CONF_SECRET_ID): vol.All(str, vol.Length(min=1)),
vol.Required(CONF_SECRET_KEY): vol.All(str, vol.Length(min=1)),
vol.Optional(CONF_REGION, default=DEFAULT_REGION): selector({
"select": {
"options": [{"label": region, "value": region} for region in self.AVAILABLE_REGIONS],
"mode": "dropdown"
}
}),
vol.Optional(CONF_PERSON_GROUP_ID, default="Hass"): vol.All(
str,
vol.Length(min=1, max=64)
),
vol.Optional(CONF_NAME, default="腾讯云人脸识别"): vol.All(str, vol.Length(min=1, max=50)),
}),
errors=errors,
description_placeholders={
"secret_id_example": "AKIDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"secret_key_example": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"person_group_id_example": "Hass",
"region_example": "ap-shanghai"
}
)
def _validate_input_format(self, user_input: Dict[str, Any], errors: Dict[str, str]) -> None:
"""验证输入格式"""
secret_id = user_input.get(CONF_SECRET_ID, "")
if not self.SECRET_ID_REGEX.match(secret_id):
errors[CONF_SECRET_ID] = "Secret ID格式不正确,应以AKID开头,后跟36个以上字符"
secret_key = user_input.get(CONF_SECRET_KEY, "")
if not self.SECRET_KEY_REGEX.match(secret_key):
errors[CONF_SECRET_KEY] = "Secret Key格式不正确,应为20个以上字符"
region = user_input.get(CONF_REGION, DEFAULT_REGION)
if region not in self.AVAILABLE_REGIONS:
errors[CONF_REGION] = "无效的区域,请从列表中选择有效的区域"
person_group_id = user_input.get(CONF_PERSON_GROUP_ID, "Hass")
if not self.PERSON_GROUP_ID_REGEX.match(person_group_id):
errors[CONF_PERSON_GROUP_ID] = "人员库ID只能包含字母、数字、连字符和下划线,长度1-64个字符"
name = user_input.get(CONF_NAME, "腾讯云人脸识别")
if len(name) > 50:
errors[CONF_NAME] = "名称长度不能超过50个字符"
async def _validate_tencent_cloud_credentials(self, user_input: Dict[str, Any]) -> Dict[str, Any]:
"""验证腾讯云凭据 - 通过真实API调用验证"""
client = None
try:
_LOGGER.info("开始验证腾讯云凭据")
client = TencentCloudClient(
user_input[CONF_SECRET_ID],
user_input[CONF_SECRET_KEY],
user_input.get(CONF_REGION, DEFAULT_REGION)
)
await self.hass.async_add_executor_job(client.verify_credentials)
_LOGGER.info("腾讯云凭据验证通过")
return {"success": True}
except AuthenticationError as ex:
_LOGGER.error("腾讯云认证失败: %s", ex)
return {
"success": False,
"errors": {
CONF_SECRET_ID: "认证失败,请检查Secret ID",
CONF_SECRET_KEY: "认证失败,请检查Secret Key",
"base": "腾讯云认证失败,请检查Secret ID和Secret Key是否正确"
}
}
except NetworkError as ex:
_LOGGER.error("腾讯云网络错误: %s", ex)
return {
"success": False,
"errors": {
CONF_REGION: "网络错误,请检查区域设置和网络连接",
"base": "连接腾讯云失败,请检查网络连接和区域设置"
}
}
except RateLimitError as ex:
_LOGGER.error("腾讯云速率限制错误: %s", ex)
return {
"success": False,
"errors": {
"base": "腾讯云API调用频率过高,请稍后再试"
}
}
except Exception as ex:
_LOGGER.error("腾讯云凭据验证失败: %s", ex)
return {
"success": False,
"errors": {
"base": f"腾讯云API验证失败: {str(ex)}"
}
}
finally:
if client is not None:
await self.hass.async_add_executor_job(client.close)
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""获取选项流程"""
return TencentFaceRecognitionOptionsFlow(config_entry)
class TencentFaceRecognitionOptionsFlow(config_entries.OptionsFlow):
"""腾讯云人脸识别选项流程"""
AVAILABLE_REGIONS = TencentFaceRecognitionConfigFlow.AVAILABLE_REGIONS
def __init__(self, config_entry):
"""初始化选项流程"""
self._entry_id = config_entry.entry_id
self._title = config_entry.title
self._data = dict(config_entry.data)
self._options = dict(config_entry.options)
async def async_step_init(self, user_input=None):
"""管理选项流程"""
if user_input is not None:
action = user_input.get("action")
if action == "config":
return await self.async_step_config()
elif action == "test_connection":
return await self.async_step_test_connection()
elif action == "reauth":
return await self.async_step_reauth()
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema({
vol.Required("action"): selector({
"select": {
"options": [
{"label": "配置设置", "value": "config"},
{"label": "测试连接", "value": "test_connection"},
{"label": "重新认证", "value": "reauth"},
{"label": "人员管理", "value": "manage"}
]
}
})
}),
description_placeholders={
"description": "请选择要管理的项目"
}
)
async def async_step_manage(self, user_input=None):
"""人员管理步骤:查看列表、创建、删除"""
errors: dict[str, str] = {}
client = None
try:
client = TencentCloudClient(
self._data.get(CONF_SECRET_ID, ""),
self._data.get(CONF_SECRET_KEY, ""),
self._data.get(CONF_REGION, DEFAULT_REGION),
)
result = await self.hass.async_add_executor_job(
client.get_person_list_all,
self._data.get(CONF_PERSON_GROUP_ID, "Hass"),
)
persons = result.get("persons", []) if result.get("success", False) else []
if user_input is not None:
action = user_input.get("person_action")
if action == "refresh":
return await self.async_step_manage()
elif action == "create":
return await self.async_step_create_person()
elif action == "delete":
selected = user_input.get("selected_person")
if selected:
await self.hass.async_add_executor_job(
client.delete_person, selected
)
_LOGGER.info("人员已删除: %s", selected)
return await self.async_step_manage()
else:
errors["base"] = "请选择要删除的人员"
person_options = [
{"label": f"{p.get('person_name', '未知')} ({p.get('person_id', '')})",
"value": p.get("person_id", "")}
for p in persons
]
schema = vol.Schema({
vol.Required("person_action"): selector({
"select": {
"options": [
{"label": "刷新列表", "value": "refresh"},
{"label": "创建人员", "value": "create"},
{"label": "删除选中人员", "value": "delete"},
]
}
}),
})
if person_options:
schema = schema.extend({
vol.Optional("selected_person"): selector({
"select": {
"options": person_options
}
})
})
return self.async_show_form(
step_id="manage",
data_schema=schema,
errors=errors,
description_placeholders={
"person_count": str(len(persons)),
"group_id": self._data.get(CONF_PERSON_GROUP_ID, "Hass"),
}
)
except Exception as ex:
_LOGGER.error("人员管理加载失败: %s", ex)
errors["base"] = f"加载人员列表失败: {ex}"
return self.async_show_form(
step_id="manage",
data_schema=vol.Schema({}),
errors=errors,
)
finally:
if client is not None:
await self.hass.async_add_executor_job(client.close)
async def async_step_create_person(self, user_input=None):
"""创建人员步骤"""
errors: dict[str, str] = {}
client = None
if user_input is not None:
person_id = user_input.get("person_id")
person_name = user_input.get("person_name")
camera = user_input.get("camera_entity")
try:
client = TencentCloudClient(
self._data.get(CONF_SECRET_ID, ""),
self._data.get(CONF_SECRET_KEY, ""),
self._data.get(CONF_REGION, DEFAULT_REGION),
)
from homeassistant.components.camera import async_get_image
import base64
image = await async_get_image(self.hass, camera)
image_base64 = base64.b64encode(image.content).decode("utf-8")
result = await self.hass.async_add_executor_job(
client.create_person,
person_id, person_name,
self._data.get(CONF_PERSON_GROUP_ID, "Hass"),
None, None, None, image_base64,
)
if result.get("success", False):
_LOGGER.info("人员创建成功: %s", person_id)
return await self.async_step_manage()
else:
errors["base"] = f"创建失败: {result.get('error_message', '未知错误')}"
except Exception as ex:
errors["base"] = f"创建人员失败: {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="create_person",
data_schema=vol.Schema({
vol.Required("person_id"): str,
vol.Required("person_name"): str,
vol.Required("camera_entity"): selector({
"entity": {"domain": "camera"}
}),
}),
errors=errors,
)
async def async_step_config(self, user_input=None):
"""配置设置步骤"""
errors = {}
if user_input is not None:
region = user_input.get(CONF_REGION)
if region not in self.AVAILABLE_REGIONS:
errors[CONF_REGION] = "无效的区域,请从列表中选择有效的区域"
person_group_id = user_input.get(CONF_PERSON_GROUP_ID, "")
if person_group_id and not re.match(r'^[a-zA-Z0-9\-_]{1,64}$', person_group_id):
errors[CONF_PERSON_GROUP_ID] = "人员库ID只能包含字母、数字、连字符和下划线,长度1-64个字符"
if not errors:
client = None
try:
client = TencentCloudClient(
self._data.get(CONF_SECRET_ID),
self._data.get(CONF_SECRET_KEY),
region
)
await self.hass.async_add_executor_job(client.verify_credentials)
_LOGGER.info("配置验证成功")
new_data = dict(self._data)
new_data[CONF_REGION] = region
if person_group_id:
new_data[CONF_PERSON_GROUP_ID] = person_group_id
entry = self.hass.config_entries.async_get_entry(self._entry_id)
self.hass.config_entries.async_update_entry(entry, data=new_data)
return self.async_create_entry(title="", data={
CONF_REGION: region,
CONF_PERSON_GROUP_ID: person_group_id,
})
except AuthenticationError as ex:
_LOGGER.error("腾讯云认证失败: %s", ex)
errors[CONF_REGION] = "认证失败,请检查区域设置"
errors["base"] = "腾讯云认证失败,请检查区域设置"
except NetworkError as ex:
_LOGGER.error("腾讯云网络错误: %s", ex)
errors[CONF_REGION] = "网络错误,请检查区域设置"
errors["base"] = "连接腾讯云失败,请检查网络连接和区域设置"
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(
CONF_REGION,
default=self._options.get(CONF_REGION, self._data.get(CONF_REGION, DEFAULT_REGION))
): selector({
"select": {
"options": [{"label": region, "value": region} for region in self.AVAILABLE_REGIONS],
"mode": "dropdown"
}
}),
vol.Optional(
CONF_PERSON_GROUP_ID,
default=self._options.get(CONF_PERSON_GROUP_ID, self._data.get(CONF_PERSON_GROUP_ID, "Hass"))
): str,
}
return self.async_show_form(
step_id="config",
data_schema=vol.Schema(options),
errors=errors,
)
async def async_step_test_connection(self, user_input=None):
"""测试连接步骤"""
errors = {}
connection_status = "未知"
if user_input is None:
client = None
try:
client = TencentCloudClient(
self._data.get(CONF_SECRET_ID),
self._data.get(CONF_SECRET_KEY),
self._data.get(CONF_REGION, DEFAULT_REGION)
)
success = await self.hass.async_add_executor_job(
client.test_api_connection,
self._data.get(CONF_PERSON_GROUP_ID, "Hass")
)
if not success:
raise Exception("连接测试失败,请检查人员库ID是否正确")
connection_status = "连接成功"
_LOGGER.info("连接测试成功")
except AuthenticationError as ex:
connection_status = f"认证失败: {str(ex)}"
errors["base"] = "认证失败,请检查Secret ID和Secret Key"
_LOGGER.error("连接测试认证失败: %s", ex)
except NetworkError as ex:
connection_status = f"网络错误: {str(ex)}"
errors["base"] = "网络错误,请检查网络连接和区域设置"
_LOGGER.error("连接测试网络失败: %s", ex)
except Exception as ex:
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",
data_schema=vol.Schema({}),
errors=errors,
description_placeholders={
"connection_status": connection_status
}
)
async def async_step_reauth(self, user_input=None):
"""重新认证步骤"""
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:
new_client = TencentCloudClient(
user_input[CONF_SECRET_ID],
user_input[CONF_SECRET_KEY],
self._data.get(CONF_REGION, DEFAULT_REGION)
)
await self.hass.async_add_executor_job(new_client.verify_credentials)
_LOGGER.info("重新认证成功")
new_data = dict(self._data)
new_data.update(user_input)
entry = self.hass.config_entries.async_get_entry(self._entry_id)
self.hass.config_entries.async_update_entry(entry, data=new_data)
await self.hass.config_entries.async_reload(entry.entry_id)
return self.async_create_entry(title="", data={})
except AuthenticationError as ex:
_LOGGER.error("重新认证失败: %s", ex)
errors["base"] = "认证失败,请检查Secret ID和Secret Key"
except NetworkError as ex:
_LOGGER.error("重新认证网络错误: %s", ex)
errors["base"] = "网络错误,请检查网络连接和区域设置"
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",
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,
description_placeholders={
"current_secret_id": self._data.get(CONF_SECRET_ID, "")[:8] + "***" if self._data.get(CONF_SECRET_ID) else "",
"current_region": self._data.get(CONF_REGION, DEFAULT_REGION)
}
)
def _validate_input_format(self, user_input: Dict[str, Any], errors: Dict[str, str]) -> None:
"""验证输入格式"""
secret_id = user_input.get(CONF_SECRET_ID, "")
if not TencentFaceRecognitionConfigFlow.SECRET_ID_REGEX.match(secret_id):
errors[CONF_SECRET_ID] = "Secret ID格式不正确,应以AKID开头,后跟36个以上字符"
secret_key = user_input.get(CONF_SECRET_KEY, "")
if not TencentFaceRecognitionConfigFlow.SECRET_KEY_REGEX.match(secret_key):
errors[CONF_SECRET_KEY] = "Secret Key格式不正确,应为20个以上字符"