From 2f19148ff15588fb8409a136acdc10dfe1da4d93 Mon Sep 17 00:00:00 2001 From: Hermes Date: Tue, 7 Jul 2026 07:27:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20HA=20=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=9D=A2=E6=9D=BF=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 界面对人员进行可视化管理 --- __init__.py | 113 +++++++++++++++++- blueprints/face_detection_automation.yaml | 134 ++++++++++++++++++++++ config_flow.py | 132 ++++++++++++++++++++- manifest.json | 2 +- 4 files changed, 377 insertions(+), 4 deletions(-) create mode 100644 blueprints/face_detection_automation.yaml diff --git a/__init__.py b/__init__.py index aca8e1c..487b152 100644 --- a/__init__.py +++ b/__init__.py @@ -4,12 +4,16 @@ from __future__ import annotations +import base64 import logging from typing import Any, Dict from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError + +from homeassistant.components import websocket_api +from homeassistant.components.camera import async_get_image from .const import ( CONF_PERSON_GROUP_ID, @@ -69,7 +73,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 4. 注册服务 await async_setup_services(hass) - # 5. 前向设置平台(sensor) + # 5. 注册 WebSocket API 命令 + websocket_api.async_register_command(hass, ws_list_persons) + websocket_api.async_register_command(hass, ws_create_person) + websocket_api.async_register_command(hass, ws_delete_person) + + # 6. 前向设置平台(sensor) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # 6. 配置项更新时自动重新加载平台 @@ -118,3 +127,103 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.async_add_executor_job(data["client"].close) except Exception: # noqa: BLE001 pass + + +# --- WebSocket API --------------------------------------------------------- + + +@websocket_api.websocket_command({ + "type": "tencent_face_recognition/list_persons", +}) +@websocket_api.async_response +async def ws_list_persons( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: Dict[str, Any] +) -> None: + """列出所有人员。""" + entry_data = None + for data in hass.data.get(DOMAIN, {}).values(): + if isinstance(data, dict) and "client" in data: + entry_data = data + break + if not entry_data: + connection.send_error(msg["id"], "not_found", "未找到配置") + return + + client: TencentCloudClient = entry_data["client"] + group_id = entry_data.get("person_group_id", "Hass") + try: + result = await hass.async_add_executor_job(client.get_person_list_all, group_id) + connection.send_result(msg["id"], result) + except Exception as ex: + _LOGGER.error("列出人员失败: %s", ex) + connection.send_error(msg["id"], "api_error", str(ex)) + + +@websocket_api.websocket_command({ + "type": "tencent_face_recognition/create_person", + "data": { + "person_id": str, + "person_name": str, + "camera_entity_id": str, + }, +}) +@websocket_api.async_response +async def ws_create_person( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: Dict[str, Any] +) -> None: + """通过摄像头创建人员。""" + entry_data = None + for data in hass.data.get(DOMAIN, {}).values(): + if isinstance(data, dict) and "client" in data: + entry_data = data + break + if not entry_data: + connection.send_error(msg["id"], "not_found", "未找到配置") + return + + client: TencentCloudClient = entry_data["client"] + group_id = entry_data.get("person_group_id", "Hass") + person_id = msg["data"]["person_id"] + person_name = msg["data"]["person_name"] + camera_entity_id = msg["data"]["camera_entity_id"] + + try: + image = await async_get_image(hass, camera_entity_id) + image_base64 = base64.b64encode(image.content).decode("utf-8") + result = await hass.async_add_executor_job( + client.create_person, + person_id, person_name, group_id, + None, None, None, image_base64, + ) + connection.send_result(msg["id"], result) + except Exception as ex: + _LOGGER.error("创建人员失败: %s", ex) + connection.send_error(msg["id"], "api_error", str(ex)) + + +@websocket_api.websocket_command({ + "type": "tencent_face_recognition/delete_person", + "data": {"person_id": str}, +}) +@websocket_api.async_response +async def ws_delete_person( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: Dict[str, Any] +) -> None: + """删除人员。""" + entry_data = None + for data in hass.data.get(DOMAIN, {}).values(): + if isinstance(data, dict) and "client" in data: + entry_data = data + break + if not entry_data: + connection.send_error(msg["id"], "not_found", "未找到配置") + return + + client: TencentCloudClient = entry_data["client"] + person_id = msg["data"]["person_id"] + try: + result = await hass.async_add_executor_job(client.delete_person, person_id) + connection.send_result(msg["id"], result) + except Exception as ex: + _LOGGER.error("删除人员失败: %s", ex) + connection.send_error(msg["id"], "api_error", str(ex)) diff --git a/blueprints/face_detection_automation.yaml b/blueprints/face_detection_automation.yaml new file mode 100644 index 0000000..72efc57 --- /dev/null +++ b/blueprints/face_detection_automation.yaml @@ -0,0 +1,134 @@ +blueprint: + name: 腾讯云人脸检测自动化 + description: 检测到人脸时自动触发,可配合摄像头抓拍 + domain: automation + input: + camera_entity: + name: 摄像头 + description: 用于人脸检测的摄像头实体 + selector: + entity: + domain: camera + face_recognition_action: + name: 检测到人脸时的操作 + description: 当检测到人脸时执行的操作(可以选择人脸搜索、检测等) + default: detect + selector: + select: + options: + - label: "仅检测人脸" + value: "detect" + - label: "搜索人脸(人员库匹配)" + value: "search" + person_group_id: + name: 人员库ID + description: 人脸搜索时使用的人员库(仅搜索模式) + default: "Hass" + max_face_num: + name: 最大人脸数 + description: 检测的最大人脸数量 + default: 3 + selector: + number: + min: 1 + max: 10 + mode: slider + notification_action: + name: 检测到已知人员时的通知 + description: 当匹配到已知人员时发送通知 + default: true + selector: + boolean: + +trigger_variables: + camera_entity: !input camera_entity + face_recognition_action: !input face_recognition_action + person_group_id: !input person_group_id + max_face_num: !input max_face_num + notification_action: !input notification_action + +trigger: + - platform: state + entity_id: !input camera_entity + attribute: motion_detected + to: "true" + - platform: homeassistant + event: start + +condition: + - condition: template + value_template: > + {% if face_recognition_action == "search" %} + {{ person_group_id | length > 0 }} + {% else %} + true + {% endif %} + +action: + - variables: + camera: !input camera_entity + action_type: !input face_recognition_action + alias: "开始人脸检测" + - if: + - condition: template + value_template: "{{ action_type == 'detect' }}" + then: + - alias: "执行人脸检测" + action: tencent_face_recognition.detect_face + data: + camera_entity_id: "{{ camera }}" + max_face_num: "{{ max_face_num | int }}" + response_variable: detection_result + else: + - alias: "执行人脸搜索" + action: tencent_face_recognition.face_search + data: + camera_entity_id: "{{ camera }}" + group_id: "{{ person_group_id }}" + max_face_num: "{{ max_face_num | int }}" + response_variable: detection_result + + - alias: "检查检测结果" + choose: + - conditions: + - condition: template + value_template: > + {{ detection_result.get('success', false) and + detection_result.get('faces', []) | length > 0 }} + sequence: + - alias: "已知人员匹配到" + if: + - condition: template + value_template: "{{ action_type == 'search' }}" + then: + - event: face_detected + event_data: + person_id: > + {{ detection_result.faces[0].candidates[0].person_id + if detection_result.faces[0].candidates else 'unknown' }} + person_name: > + {{ detection_result.faces[0].candidates[0].person_name + if detection_result.faces[0].candidates else '未知' }} + score: > + {{ detection_result.faces[0].candidates[0].score + if detection_result.faces[0].candidates else 0 }} + - if: + - condition: template + value_template: "{{ notification_action }}" + then: + - alias: "发送通知" + action: persistent_notification.create + data: + title: "人脸识别 - 已知人员" + message: > + 检测到已知人员:{{ detection_result.faces[0].candidates[0].person_name + if detection_result.faces[0].candidates else '未知' }} + (匹配度:{{ detection_result.faces[0].candidates[0].score + if detection_result.faces[0].candidates else 0 }}%) + else: + - event: face_detected + event_data: + face_count: "{{ detection_result.faces | length }}" + details: "{{ detection_result.faces }}" + +mode: single diff --git a/config_flow.py b/config_flow.py index b4bca0d..9c4ea23 100644 --- a/config_flow.py +++ b/config_flow.py @@ -239,7 +239,8 @@ class TencentFaceRecognitionOptionsFlow(config_entries.OptionsFlow): "options": [ {"label": "配置设置", "value": "config"}, {"label": "测试连接", "value": "test_connection"}, - {"label": "重新认证", "value": "reauth"} + {"label": "重新认证", "value": "reauth"}, + {"label": "人员管理", "value": "manage"} ] } }) @@ -249,6 +250,135 @@ class TencentFaceRecognitionOptionsFlow(config_entries.OptionsFlow): } ) + 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 = {} diff --git a/manifest.json b/manifest.json index 110a91c..d3a867b 100644 --- a/manifest.json +++ b/manifest.json @@ -11,7 +11,7 @@ ], "dependencies": [], "codeowners": ["@xyzmos"], - "version": "2.1.0", + "version": "2.2.0", "iot_class": "cloud_polling", "resources": [] }