├── hacs.json ├── custom_components └── bemfa_to_homeassistant │ ├── __pycache__ │ ├── const.cpython-312.pyc │ ├── __init__.cpython-312.pyc │ └── config_flow.cpython-312.pyc │ ├── translations │ ├── zh-Hans.json │ └── en.json │ ├── manifest.json │ ├── helpers.py │ ├── const.py │ ├── config_flow.py │ ├── switch.py │ ├── cover.py │ ├── light.py │ ├── fan.py │ ├── __init__.py │ ├── climate.py │ └── sensor.py ├── LICENSE └── README.md /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bemfa_to_homeassistant 巴法云", 3 | "render_readme": true, 4 | "country": "CN", 5 | "homeassistant": "2022.5.5" 6 | } 7 | -------------------------------------------------------------------------------- /custom_components/bemfa_to_homeassistant/__pycache__/const.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LanYanY/bemfa_to_homeassistant/HEAD/custom_components/bemfa_to_homeassistant/__pycache__/const.cpython-312.pyc -------------------------------------------------------------------------------- /custom_components/bemfa_to_homeassistant/__pycache__/__init__.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LanYanY/bemfa_to_homeassistant/HEAD/custom_components/bemfa_to_homeassistant/__pycache__/__init__.cpython-312.pyc -------------------------------------------------------------------------------- /custom_components/bemfa_to_homeassistant/__pycache__/config_flow.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LanYanY/bemfa_to_homeassistant/HEAD/custom_components/bemfa_to_homeassistant/__pycache__/config_flow.cpython-312.pyc -------------------------------------------------------------------------------- /custom_components/bemfa_to_homeassistant/translations/zh-Hans.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "api_key": "API密钥" 7 | }, 8 | "description": "请输入巴法云的API密钥(UID)", 9 | "title": "巴法云配置" 10 | } 11 | }, 12 | "error": { 13 | "invalid_auth": "API密钥无效", 14 | "unknown": "发生未知错误" 15 | }, 16 | "abort": { 17 | "already_configured": "设备已经配置" 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /custom_components/bemfa_to_homeassistant/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "bemfa_to_homeassistant", 3 | "name": "Bemfa To Home Assistant", 4 | "documentation": "https://github.com/LanYanY/bemfa_to_homeassistant", 5 | "issue_tracker": "https://github.com/LanYanY/bemfa_to_homeassistant/issues", 6 | "dependencies": [], 7 | "codeowners": [], 8 | "requirements": [ 9 | "requests>=2.25.1", 10 | "paho-mqtt>=1.5.1" 11 | ], 12 | "version": "1.0.0", 13 | "config_flow": true, 14 | "iot_class": "cloud_push", 15 | "loggers": ["bemfa_to_homeassistant"], 16 | "icon": "mdi:cloud-sync" 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /custom_components/bemfa_to_homeassistant/helpers.py: -------------------------------------------------------------------------------- 1 | """巴法云集成的辅助函数.""" 2 | from __future__ import annotations 3 | 4 | from dataclasses import dataclass 5 | from typing import Final 6 | 7 | from homeassistant.helpers.device_registry import DeviceInfo 8 | from homeassistant.helpers.entity import Entity 9 | 10 | from .const import DOMAIN, MANUFACTURER, MODEL 11 | 12 | @dataclass 13 | class BemfaDeviceInfo: 14 | """巴法云设备信息.""" 15 | topic: str 16 | name: str 17 | type: str 18 | state: str = "" 19 | online: bool = True 20 | 21 | def get_device_info(topic: str, name: str) -> DeviceInfo: 22 | """获取设备信息.""" 23 | return DeviceInfo( 24 | identifiers={(DOMAIN, topic)}, 25 | name=name, 26 | manufacturer=MANUFACTURER, 27 | model=MODEL, 28 | sw_version="1.0", 29 | via_device=(DOMAIN, topic), 30 | ) 31 | 32 | class BemfaBaseEntity(Entity): 33 | """巴法云基础实体类.""" 34 | 35 | _attr_should_poll = False 36 | _attr_has_entity_name = True 37 | 38 | def __init__(self, topic: str, name: str) -> None: 39 | """初始化基础实体.""" 40 | self._attr_device_info = get_device_info(topic, name) 41 | self._attr_unique_id = f"{DOMAIN}_{topic}" -------------------------------------------------------------------------------- /custom_components/bemfa_to_homeassistant/const.py: -------------------------------------------------------------------------------- 1 | """巴法云集成的常量定义.""" 2 | from typing import Final 3 | from homeassistant.const import Platform 4 | from enum import StrEnum 5 | 6 | DOMAIN: Final = "bemfa_to_homeassistant" 7 | CONF_API_KEY: Final = "api_key" 8 | 9 | # MQTT 设置 10 | MQTT_HOST: Final = "bemfa.com" 11 | MQTT_PORT: Final = 9501 12 | MQTT_KEEPALIVE: Final = 600 13 | 14 | TOPIC_PREFIX: Final = "hass" 15 | TOPIC_PING: Final = f"{TOPIC_PREFIX}ping" 16 | 17 | # MQTT 心跳间隔 18 | INTERVAL_PING_SEND: Final = 30 19 | INTERVAL_PING_RECEIVE: Final = 20 20 | MAX_PING_LOST: Final = 3 21 | 22 | # 消息格式 23 | MSG_SEPARATOR: Final = "#" 24 | MSG_ON: Final = "on" 25 | MSG_OFF: Final = "off" 26 | 27 | # API URLs 28 | BEMFA_API_URL: Final = "https://apis.bemfa.com/va/alltopic" 29 | 30 | # 更新间隔 31 | DEFAULT_SCAN_INTERVAL: Final = 30 32 | 33 | # 设备信息 34 | MANUFACTURER = "巴法云" 35 | MODEL = "巴法云智能设备" 36 | 37 | # 支持的平台 38 | PLATFORMS: Final = [ 39 | Platform.LIGHT, 40 | Platform.SWITCH, 41 | Platform.FAN, 42 | Platform.SENSOR, 43 | Platform.CLIMATE, 44 | Platform.COVER, 45 | ] 46 | 47 | class BemfaDeviceType(StrEnum): 48 | """巴法云设备类型.""" 49 | SWITCH = "001" 50 | LIGHT = "002" 51 | FAN = "003" 52 | SENSOR = "004" 53 | CLIMATE = "005" 54 | SWITCH_PANEL = "006" 55 | COVER = "009" 56 | 57 | # 设备类型映射 58 | DEVICE_TYPES = { 59 | BemfaDeviceType.SWITCH: "switch", 60 | BemfaDeviceType.LIGHT: "light", 61 | BemfaDeviceType.FAN: "fan", 62 | BemfaDeviceType.SENSOR: "sensor", 63 | BemfaDeviceType.CLIMATE: "climate", 64 | BemfaDeviceType.SWITCH_PANEL: "switch", 65 | BemfaDeviceType.COVER: "cover", 66 | } 67 | -------------------------------------------------------------------------------- /custom_components/bemfa_to_homeassistant/config_flow.py: -------------------------------------------------------------------------------- 1 | """巴法云集成的配置流程.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import Any 6 | 7 | import requests 8 | import voluptuous as vol 9 | 10 | from homeassistant import config_entries 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.data_entry_flow import FlowResult 13 | from homeassistant.exceptions import HomeAssistantError 14 | 15 | from .const import DOMAIN, CONF_API_KEY, BEMFA_API_URL 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | STEP_USER_DATA_SCHEMA = vol.Schema({ 20 | vol.Required(CONF_API_KEY): str, 21 | }) 22 | 23 | async def validate_api_key(hass: HomeAssistant, api_key: str) -> bool: 24 | """验证API密钥.""" 25 | try: 26 | def _validate(): 27 | response = requests.get( 28 | BEMFA_API_URL, 29 | params={"uid": api_key, "type": "1"}, 30 | timeout=10 31 | ) 32 | response.raise_for_status() 33 | return response.json().get("code") == 0 34 | 35 | return await hass.async_add_executor_job(_validate) 36 | except requests.RequestException: 37 | return False 38 | 39 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 40 | """处理配置流程.""" 41 | 42 | VERSION = 1 43 | 44 | async def async_step_user( 45 | self, user_input: dict[str, Any] | None = None 46 | ) -> FlowResult: 47 | """处理用户输入.""" 48 | errors = {} 49 | 50 | if user_input is not None: 51 | try: 52 | if await validate_api_key(self.hass, user_input[CONF_API_KEY]): 53 | return self.async_create_entry( 54 | title="巴法云", 55 | data=user_input, 56 | ) 57 | errors["base"] = "invalid_auth" 58 | except Exception: 59 | errors["base"] = "unknown" 60 | 61 | return self.async_show_form( 62 | step_id="user", 63 | data_schema=STEP_USER_DATA_SCHEMA, 64 | errors=errors, 65 | ) 66 | 67 | class CannotConnect(HomeAssistantError): 68 | """表示无法连接的错误.""" 69 | 70 | class InvalidAuth(HomeAssistantError): 71 | """表示认证无效的错误.""" 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Home Assistant 巴法云集成 2 | 3 | 这是一个用于 Home Assistant 的巴法云集成组件。通过这个组件,你可以在 Home Assistant 中控制和监控你的巴法云设备。 4 | 5 | **实现与larry-wong/bemfa相反的功能!** 6 | 7 | ## 功能特点 8 | 9 | - 支持多种设备类型: 10 | - 开关 11 | - 灯光(支持亮度和色温调节) 12 | - 风扇(支持速度和摇头控制) 13 | - 空调(支持温度、模式、风速和扫风控制) 14 | - 窗帘(支持开关和位置控制) 15 | - 传感器(温度、湿度、光照等) 16 | - 实时状态更新 17 | - 稳定的 MQTT 连接 18 | - 简单的配置流程 19 | 20 | ## 安装 21 | 22 | ### HACS 安装 (推荐) 23 | 24 | 1. 打开 HACS 25 | 2. 点击集成 26 | 3. 点击右上角的三个点 27 | 4. 选择"添加自定义仓库" 28 | 5. 输入仓库地址: `https://github.com/LanYanY/bemfa_to_homeassistant` 29 | 6. 选择类别为"集成" 30 | 7. 点击"添加" 31 | 8. 在 HACS 中搜索"bemfa"并安装 32 | 33 | ### 手动安装 34 | 35 | 1. 下载此仓库的最新版本 36 | 2. 将 `custom_components/bemfa_to_homeassistant` 文件夹复制到你的 Home Assistant 配置目录下的 `custom_components` 文件夹中 37 | 3. 重启 Home Assistant 38 | 39 | ## 配置 40 | 41 | 1. 在 Home Assistant 的配置 -> 集成中点击添加集成 42 | 2. 搜索"巴法云" 43 | 3. 输入你的巴法云 API 密钥 44 | 4. 点击提交 45 | 46 | ## 支持的设备类型 47 | 48 | | 设备类型 | 主题后缀 | 功能和消息格式 | 49 | |---------|---------|----------------| 50 | | 开关/插座 | 006/001 | - 开:`on`
- 关:`off` | 51 | | 灯光 | 002 | - 开/关:`on`/`off`
- 亮度:`on#亮度值`(范围:1-100)
- 颜色:`on#亮度值#rgb值`
- 色温:`on#亮度值#色温值`(范围:2700-6500) | 52 | | 风扇 | 003 | - 开/关:`on`/`off`
- 档位:`on#档位`(1-4档)
- 摇头:`on#档位#1`(开启)
`on#档位#0`(关闭) | 53 | | 空调 | 005 | - 开/关:`on`/`off`
- 模式:`on#模式#温度`
模式值:2=制冷,3=制热,4=送风,
5=除湿,6=睡眠,7=节能
- 温度范围:16-32°C | 54 | | 窗帘 | 009 | - 开/关:`on`/`off`
- 暂停:`pause`
- 位置:`on#位置值`(0-100) | 55 | | 传感器 | 004 | 数据格式:`#温度#湿度#开关#光照#pm2.5#心率`
(可选参数,但#号必须保留) | 56 | 57 | 可参考此文档: 58 | - [巴法文档中心-天猫精灵接入](https://cloud.bemfa.com/docs/src/speaker_mall.html) 59 | 60 | ![image](https://github.com/user-attachments/assets/73ce899b-050a-493d-8ca1-fad0969bbdcb) 61 | 62 | 63 | 64 | ## 主题命名规则 65 | 66 | 设备类型由主题名称的最后三位数字决定,例如: 67 | - `light002` - 灯光设备 68 | - `fan003` - 风扇设备 69 | - `sensor004` - 传感器 70 | - `ac005` - 空调 71 | - `switch006` - 开关 72 | - `curtain009` - 窗帘 73 | 74 | ### 升级说明 75 | 如果你从旧版本升级,无需进行任何额外配置,集成会自动适应新的变更。 76 | 77 | ## 常见问题 78 | 79 | **Q: 设备无法连接怎么办?** 80 | A: 请检查: 81 | 1. API 密钥是否正确 82 | 2. 设备是否在线 83 | 3. 网络连接是否正常 84 | 85 | **Q: 状态更新不及时怎么办?** 86 | A: 可以尝试: 87 | 1. 检查网络连接 88 | 2. 重启 Home Assistant 89 | 3. 重新添加集成 90 | 91 | ## 贡献 92 | 93 | 欢迎提交 Issue 和 Pull Request! 94 | 95 | ## 致谢 96 | - [larry-wong/bemfa](https://github.com/larry-wong/bemfa) - 感谢这个优秀的开源项目提供的参考和灵感 97 | - [Home Assistant](https://www.home-assistant.io/) 98 | - [巴法云](https://www.bemfa.com/) 99 | 100 | ## 免责声明 101 | 102 | 本项目不隶属于巴法云官方。使用本集成时请遵守巴法云的服务条款。 103 | -------------------------------------------------------------------------------- /custom_components/bemfa_to_homeassistant/switch.py: -------------------------------------------------------------------------------- 1 | """巴法云开关设备平台.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import Any 6 | 7 | from homeassistant.components.switch import SwitchEntity 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 11 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 12 | 13 | from .const import ( 14 | DOMAIN, 15 | CONF_API_KEY, 16 | MSG_ON, 17 | MSG_OFF, 18 | ) 19 | from .helpers import BemfaBaseEntity 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | async def async_setup_entry( 24 | hass: HomeAssistant, 25 | entry: ConfigEntry, 26 | async_add_entities: AddEntitiesCallback, 27 | ) -> None: 28 | """设置巴法云开关设备.""" 29 | coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] 30 | mqtt_client = hass.data[DOMAIN][entry.entry_id]["mqtt_client"] 31 | 32 | entities = [ 33 | BemfaSwitch(coordinator, mqtt_client, topic, entry) 34 | for topic, device in coordinator.data.items() 35 | if device["type"] == "switch" 36 | ] 37 | 38 | if entities: 39 | async_add_entities(entities) 40 | 41 | class BemfaSwitch(CoordinatorEntity, BemfaBaseEntity, SwitchEntity): 42 | """巴法云开关设备.""" 43 | 44 | _attr_has_entity_name = True 45 | 46 | def __init__(self, coordinator, mqtt_client, topic, entry): 47 | """初始化巴法云开关设备.""" 48 | super().__init__(coordinator) 49 | BemfaBaseEntity.__init__(self, topic, coordinator.data[topic]["name"]) 50 | 51 | self._topic = topic 52 | self._mqtt_client = mqtt_client 53 | self._api_key = entry.data[CONF_API_KEY] 54 | self._attr_unique_id = f"{DOMAIN}_{topic}_switch" 55 | self._attr_name = "开关" 56 | 57 | self._parse_state(coordinator.data[topic].get("state", "")) 58 | 59 | def _parse_state(self, state: str) -> None: 60 | """解析设备状态.""" 61 | self._attr_is_on = state.lower() == MSG_ON 62 | 63 | async def async_turn_on(self, **kwargs: Any) -> None: 64 | """打开开关.""" 65 | await self._async_send_command(MSG_ON) 66 | 67 | async def async_turn_off(self, **kwargs: Any) -> None: 68 | """关闭开关.""" 69 | await self._async_send_command(MSG_OFF) 70 | 71 | async def _async_send_command(self, command: str) -> None: 72 | """发送命令到巴法云.""" 73 | try: 74 | self._mqtt_client.publish(f"{self._topic}/set", command) 75 | self._attr_is_on = command.lower() == MSG_ON 76 | self.async_write_ha_state() 77 | await self.coordinator.async_request_refresh() 78 | except Exception as ex: 79 | _LOGGER.error("发送命令失败: %s", ex) 80 | 81 | @property 82 | def available(self) -> bool: 83 | """返回设备是否可用.""" 84 | return self.coordinator.data.get(self._topic, {}).get("online", True) 85 | 86 | def _handle_coordinator_update(self) -> None: 87 | """处理设备状态更新.""" 88 | device = self.coordinator.data.get(self._topic, {}) 89 | self._parse_state(device.get("state", "")) 90 | self.hass.loop.call_soon_threadsafe(self.async_write_ha_state) -------------------------------------------------------------------------------- /custom_components/bemfa_to_homeassistant/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "This UID has been already configured" 5 | }, 6 | "error": { 7 | "invalid_uid": "Invalid UID" 8 | }, 9 | "step": { 10 | "user": { 11 | "data": { 12 | "uid": "Bemfa UID" 13 | }, 14 | "title": "Bemfa User" 15 | } 16 | } 17 | }, 18 | "options": { 19 | "step": { 20 | "init": { 21 | "title": "Operations", 22 | "menu_options": { 23 | "create_sync": "Create sync", 24 | "modify_sync": "Modify sync", 25 | "destroy_sync": "Destroy sync(s)" 26 | } 27 | }, 28 | "create_sync": { 29 | "title": "Create sync", 30 | "description": "Select an entity to create sync." 31 | }, 32 | "modify_sync": { 33 | "title": "Modify a sync", 34 | "description": "Select a sync to edit." 35 | }, 36 | "sync_config_sensor": { 37 | "title": "Configuation", 38 | "description": "Configuate a hass-to-bemfa sensor sync.", 39 | "data": { 40 | "name": "Name", 41 | "temperature": "Select a temperature sensor", 42 | "humidity": "Select a humidity sensor", 43 | "illuminance": "Select an illuminance sensor", 44 | "pm25": "Select a pm25 sensor" 45 | } 46 | }, 47 | "sync_config_binary_sensor": { 48 | "title": "Configuation", 49 | "description": "Configuate a hass-to-bemfa binary sensor sync.", 50 | "data": { 51 | "name": "Name" 52 | } 53 | }, 54 | "sync_config_climate": { 55 | "title": "Configuation", 56 | "description": "Configuate a hass-to-bemfa climate sync.", 57 | "data": { 58 | "name": "Name", 59 | "fan_speed_0_value": "Fan speed auto", 60 | "fan_speed_1_value": "Fan speed 1", 61 | "fan_speed_2_value": "Fan speed 2", 62 | "fan_speed_3_value": "Fan speed 3", 63 | "fan_speed_4_value": "Fan speed 4", 64 | "fan_speed_5_value": "Fan speed 5", 65 | "swing_off_value": "Swing off", 66 | "swing_horizontal_value": "Swing horizontal", 67 | "swing_vertical_value": "Swing vertical", 68 | "swing_both_value": "Swing both" 69 | } 70 | }, 71 | "sync_config_cover": { 72 | "title": "Configuation", 73 | "description": "Configuate a hass-to-bemfa cover sync.", 74 | "data": { 75 | "name": "Name" 76 | } 77 | }, 78 | "sync_config_fan": { 79 | "title": "Configuation", 80 | "description": "Configuate a hass-to-bemfa fan sync.", 81 | "data": { 82 | "name": "Name" 83 | } 84 | }, 85 | "sync_config_light": { 86 | "title": "Configuation", 87 | "description": "Configuate a hass-to-bemfa light sync.", 88 | "data": { 89 | "name": "Name" 90 | } 91 | }, 92 | "sync_config_switch": { 93 | "title": "Configuation", 94 | "description": "Configuate a hass-to-bemfa switch sync.", 95 | "data": { 96 | "name": "Name" 97 | } 98 | }, 99 | "destroy_sync": { 100 | "title": "Destroy sync(s)", 101 | "description": "Select sync(s) to destroy." 102 | }, 103 | "empty": { 104 | "title": "Empty", 105 | "description": "No syncs found." 106 | } 107 | } 108 | } 109 | } -------------------------------------------------------------------------------- /custom_components/bemfa_to_homeassistant/cover.py: -------------------------------------------------------------------------------- 1 | """巴法云窗帘设备平台.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import Any 6 | 7 | from homeassistant.components.cover import ( 8 | CoverEntity, 9 | CoverEntityFeature, 10 | ATTR_POSITION, 11 | ) 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 15 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 16 | 17 | from .const import ( 18 | DOMAIN, 19 | CONF_API_KEY, 20 | MSG_ON, 21 | MSG_OFF, 22 | MSG_SEPARATOR, 23 | ) 24 | from .helpers import BemfaBaseEntity 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | COVER_PAUSE = "pause" 29 | 30 | async def async_setup_entry( 31 | hass: HomeAssistant, 32 | entry: ConfigEntry, 33 | async_add_entities: AddEntitiesCallback, 34 | ) -> None: 35 | """设置巴法云窗帘设备.""" 36 | coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] 37 | mqtt_client = hass.data[DOMAIN][entry.entry_id]["mqtt_client"] 38 | 39 | entities = [ 40 | BemfaCover(coordinator, mqtt_client, topic, entry) 41 | for topic, device in coordinator.data.items() 42 | if device["type"] == "cover" 43 | ] 44 | 45 | if entities: 46 | async_add_entities(entities) 47 | 48 | class BemfaCover(CoordinatorEntity, BemfaBaseEntity, CoverEntity): 49 | """巴法云窗帘设备.""" 50 | 51 | _attr_has_entity_name = True 52 | _attr_supported_features = ( 53 | CoverEntityFeature.OPEN | 54 | CoverEntityFeature.CLOSE | 55 | CoverEntityFeature.STOP | 56 | CoverEntityFeature.SET_POSITION 57 | ) 58 | 59 | def __init__(self, coordinator, mqtt_client, topic, entry): 60 | """初始化巴法云窗帘设备.""" 61 | super().__init__(coordinator) 62 | BemfaBaseEntity.__init__(self, topic, coordinator.data[topic]["name"]) 63 | 64 | self._topic = topic 65 | self._mqtt_client = mqtt_client 66 | self._api_key = entry.data[CONF_API_KEY] 67 | self._attr_unique_id = f"{DOMAIN}_{topic}_cover" 68 | self._attr_name = "窗帘" 69 | 70 | self._parse_state(coordinator.data[topic].get("state", "")) 71 | 72 | def _parse_state(self, state: str) -> None: 73 | """解析设备状态.""" 74 | if not state: 75 | self._attr_is_closed = True 76 | self._attr_current_cover_position = 0 77 | return 78 | 79 | parts = state.split(MSG_SEPARATOR) 80 | 81 | if parts[0] == MSG_OFF: 82 | self._attr_is_closed = True 83 | self._attr_current_cover_position = 0 84 | elif parts[0] == MSG_ON: 85 | self._attr_is_closed = False 86 | self._attr_current_cover_position = 100 87 | elif parts[0] == COVER_PAUSE: 88 | pass 89 | 90 | if len(parts) > 1: 91 | try: 92 | position = int(parts[1]) 93 | self._attr_current_cover_position = position 94 | self._attr_is_closed = position == 0 95 | except (ValueError, IndexError): 96 | pass 97 | 98 | async def async_open_cover(self, **kwargs: Any) -> None: 99 | """打开窗帘.""" 100 | await self._async_send_command(MSG_ON) 101 | 102 | async def async_close_cover(self, **kwargs: Any) -> None: 103 | """关闭窗帘.""" 104 | await self._async_send_command(MSG_OFF) 105 | 106 | async def async_stop_cover(self, **kwargs: Any) -> None: 107 | """停止窗帘.""" 108 | await self._async_send_command(COVER_PAUSE) 109 | 110 | async def async_set_cover_position(self, **kwargs: Any) -> None: 111 | """设置窗帘位置.""" 112 | position = kwargs.get(ATTR_POSITION) 113 | if position is not None: 114 | msg = f"{MSG_ON}#{position}" 115 | await self._async_send_command(msg) 116 | 117 | async def _async_send_command(self, command: str) -> None: 118 | """发送命令到巴法云.""" 119 | try: 120 | self._mqtt_client.publish(f"{self._topic}/set", command) 121 | self._parse_state(command) 122 | self.async_write_ha_state() 123 | await self.coordinator.async_request_refresh() 124 | except Exception as ex: 125 | _LOGGER.error("发送命令失败: %s", ex) 126 | 127 | @property 128 | def available(self) -> bool: 129 | """返回设备是否可用.""" 130 | return self.coordinator.data.get(self._topic, {}).get("online", True) 131 | 132 | def _handle_coordinator_update(self) -> None: 133 | """处理设备状态更新.""" 134 | device = self.coordinator.data.get(self._topic, {}) 135 | self._parse_state(device.get("state", "")) 136 | self.hass.loop.call_soon_threadsafe(self.async_write_ha_state) -------------------------------------------------------------------------------- /custom_components/bemfa_to_homeassistant/light.py: -------------------------------------------------------------------------------- 1 | """巴法云灯光设备平台.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import Any 6 | 7 | from homeassistant.components.light import ( 8 | ATTR_BRIGHTNESS, 9 | ATTR_COLOR_TEMP_KELVIN, 10 | ColorMode, 11 | LightEntity, 12 | ) 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.core import HomeAssistant 15 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 16 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 17 | from homeassistant.util.color import ( 18 | color_temperature_kelvin_to_mired, 19 | color_temperature_mired_to_kelvin, 20 | ) 21 | 22 | from .const import ( 23 | DOMAIN, 24 | CONF_API_KEY, 25 | MSG_SEPARATOR, 26 | ) 27 | from .helpers import BemfaBaseEntity 28 | 29 | _LOGGER = logging.getLogger(__name__) 30 | 31 | # 色温范围(开尔文) 32 | MIN_KELVIN = 2700 # 暖光 33 | MAX_KELVIN = 6500 # 冷光 34 | 35 | async def async_setup_entry( 36 | hass: HomeAssistant, 37 | entry: ConfigEntry, 38 | async_add_entities: AddEntitiesCallback, 39 | ) -> None: 40 | """设置巴法云灯光设备.""" 41 | coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] 42 | mqtt_client = hass.data[DOMAIN][entry.entry_id]["mqtt_client"] 43 | 44 | entities = [ 45 | BemfaLight(coordinator, mqtt_client, topic, entry) 46 | for topic, device in coordinator.data.items() 47 | if device["type"] == "light" 48 | ] 49 | 50 | if entities: 51 | async_add_entities(entities) 52 | 53 | class BemfaLight(CoordinatorEntity, BemfaBaseEntity, LightEntity): 54 | """巴法云灯光设备.""" 55 | 56 | _attr_has_entity_name = True 57 | _attr_color_mode = ColorMode.COLOR_TEMP 58 | _attr_supported_color_modes = {ColorMode.COLOR_TEMP} 59 | _attr_min_mireds = color_temperature_kelvin_to_mired(MAX_KELVIN) 60 | _attr_max_mireds = color_temperature_kelvin_to_mired(MIN_KELVIN) 61 | 62 | def __init__(self, coordinator, mqtt_client, topic, entry): 63 | """初始化巴法云灯光设备.""" 64 | super().__init__(coordinator) 65 | BemfaBaseEntity.__init__(self, topic, coordinator.data[topic]["name"]) 66 | 67 | self._topic = topic 68 | self._mqtt_client = mqtt_client 69 | self._api_key = entry.data[CONF_API_KEY] 70 | self._attr_unique_id = f"{DOMAIN}_{topic}_light" 71 | self._attr_name = "灯光" 72 | 73 | self._parse_state(coordinator.data[topic].get("state", "")) 74 | 75 | def _parse_state(self, state: str) -> None: 76 | """解析设备状态.""" 77 | if not state: 78 | self._attr_is_on = False 79 | self._attr_brightness = 0 80 | self._attr_color_temp = self.max_mireds 81 | return 82 | 83 | parts = state.split(MSG_SEPARATOR) 84 | self._attr_is_on = parts[0].lower() == "on" 85 | 86 | if len(parts) > 1 and self._attr_is_on: 87 | try: 88 | brightness_pct = float(parts[1]) 89 | self._attr_brightness = int(min(255, max(0, brightness_pct * 255 / 100))) 90 | except (ValueError, IndexError): 91 | self._attr_brightness = 255 if self._attr_is_on else 0 92 | else: 93 | self._attr_brightness = 255 if self._attr_is_on else 0 94 | 95 | if len(parts) > 2 and self._attr_is_on: 96 | try: 97 | kelvin = round(int(parts[2]) / 100) * 100 98 | kelvin = max(MIN_KELVIN, min(MAX_KELVIN, kelvin)) 99 | self._attr_color_temp = color_temperature_kelvin_to_mired(kelvin) 100 | except (ValueError, IndexError): 101 | self._attr_color_temp = self.max_mireds 102 | else: 103 | self._attr_color_temp = self.max_mireds 104 | 105 | async def async_turn_on(self, **kwargs: Any) -> None: 106 | """打开灯.""" 107 | brightness = kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness or 255) 108 | kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN, color_temperature_mired_to_kelvin(self._attr_color_temp)) 109 | 110 | brightness_pct = max(1, min(100, round(brightness * 100 / 255))) 111 | kelvin = round(kelvin / 100) * 100 112 | kelvin = max(MIN_KELVIN, min(MAX_KELVIN, kelvin)) 113 | 114 | msg = f"on#{brightness_pct}#{kelvin}" 115 | await self._async_send_command(msg) 116 | 117 | async def async_turn_off(self, **kwargs: Any) -> None: 118 | """关闭灯.""" 119 | await self._async_send_command("off") 120 | 121 | async def _async_send_command(self, command: str) -> None: 122 | """发送命令到巴法云.""" 123 | try: 124 | self._mqtt_client.publish(f"{self._topic}/set", command) 125 | self._parse_state(command) 126 | self.async_write_ha_state() 127 | await self.coordinator.async_request_refresh() 128 | except Exception as ex: 129 | _LOGGER.error("发送命令失败: %s", ex) 130 | 131 | @property 132 | def available(self) -> bool: 133 | """返回设备是否可用.""" 134 | return self.coordinator.data.get(self._topic, {}).get("online", True) 135 | 136 | def _handle_coordinator_update(self) -> None: 137 | """处理设备状态更新.""" 138 | device = self.coordinator.data.get(self._topic, {}) 139 | self._parse_state(device.get("state", "")) 140 | self.hass.loop.call_soon_threadsafe(self.async_write_ha_state) -------------------------------------------------------------------------------- /custom_components/bemfa_to_homeassistant/fan.py: -------------------------------------------------------------------------------- 1 | """巴法云风扇设备平台.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import Any 6 | 7 | from homeassistant.components.fan import ( 8 | FanEntity, 9 | FanEntityFeature, 10 | ) 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 14 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 15 | from homeassistant.util.percentage import ( 16 | ordered_list_item_to_percentage, 17 | percentage_to_ordered_list_item, 18 | ) 19 | 20 | from .const import ( 21 | DOMAIN, 22 | CONF_API_KEY, 23 | MSG_ON, 24 | MSG_OFF, 25 | MSG_SEPARATOR, 26 | ) 27 | from .helpers import BemfaBaseEntity 28 | 29 | _LOGGER = logging.getLogger(__name__) 30 | 31 | # 风扇速度列表 32 | SPEED_LIST = [1, 2, 3, 4] 33 | 34 | async def async_setup_entry( 35 | hass: HomeAssistant, 36 | entry: ConfigEntry, 37 | async_add_entities: AddEntitiesCallback, 38 | ) -> None: 39 | """设置巴法云风扇设备.""" 40 | coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] 41 | mqtt_client = hass.data[DOMAIN][entry.entry_id]["mqtt_client"] 42 | 43 | entities = [ 44 | BemfaFan(coordinator, mqtt_client, topic, entry) 45 | for topic, device in coordinator.data.items() 46 | if device["type"] == "fan" 47 | ] 48 | 49 | if entities: 50 | async_add_entities(entities) 51 | 52 | class BemfaFan(CoordinatorEntity, BemfaBaseEntity, FanEntity): 53 | """巴法云风扇设备.""" 54 | 55 | _attr_has_entity_name = True 56 | _attr_supported_features = ( 57 | FanEntityFeature.SET_SPEED | 58 | FanEntityFeature.OSCILLATE 59 | ) 60 | _attr_speed_count = len(SPEED_LIST) 61 | 62 | def __init__(self, coordinator, mqtt_client, topic, entry): 63 | """初始化巴法云风扇设备.""" 64 | super().__init__(coordinator) 65 | BemfaBaseEntity.__init__(self, topic, coordinator.data[topic]["name"]) 66 | 67 | self._topic = topic 68 | self._mqtt_client = mqtt_client 69 | self._api_key = entry.data[CONF_API_KEY] 70 | self._attr_unique_id = f"{DOMAIN}_{topic}_fan" 71 | self._attr_name = "风扇" 72 | 73 | self._parse_state(coordinator.data[topic].get("state", "")) 74 | 75 | def _parse_state(self, state: str) -> None: 76 | """解析设备状态.""" 77 | if not state: 78 | self._attr_is_on = False 79 | self._attr_percentage = 0 80 | self._attr_oscillating = False 81 | return 82 | 83 | parts = state.split(MSG_SEPARATOR) 84 | self._attr_is_on = parts[0].lower() == MSG_ON 85 | 86 | if len(parts) > 1 and self._attr_is_on: 87 | try: 88 | speed = int(parts[1]) 89 | if 1 <= speed <= 4: 90 | self._attr_percentage = ordered_list_item_to_percentage(SPEED_LIST, speed) 91 | else: 92 | self._attr_percentage = 0 93 | except (ValueError, IndexError): 94 | self._attr_percentage = 0 95 | else: 96 | self._attr_percentage = 0 97 | 98 | if len(parts) > 2 and self._attr_is_on: 99 | self._attr_oscillating = parts[2] == "1" 100 | else: 101 | self._attr_oscillating = False 102 | 103 | async def async_turn_on( 104 | self, 105 | percentage: int | None = None, 106 | preset_mode: str | None = None, 107 | **kwargs: Any, 108 | ) -> None: 109 | """打开风扇.""" 110 | if percentage is None: 111 | percentage = self.percentage or ordered_list_item_to_percentage(SPEED_LIST, 1) 112 | 113 | speed = percentage_to_ordered_list_item(SPEED_LIST, percentage) 114 | oscillating = "1" if self.oscillating else "0" 115 | 116 | msg = f"{MSG_ON}#{speed}#{oscillating}" 117 | await self._async_send_command(msg) 118 | 119 | async def async_turn_off(self, **kwargs: Any) -> None: 120 | """关闭风扇.""" 121 | await self._async_send_command(MSG_OFF) 122 | 123 | async def async_set_percentage(self, percentage: int) -> None: 124 | """设置风扇速度.""" 125 | if percentage == 0: 126 | await self.async_turn_off() 127 | else: 128 | speed = percentage_to_ordered_list_item(SPEED_LIST, percentage) 129 | oscillating = "1" if self.oscillating else "0" 130 | msg = f"{MSG_ON}#{speed}#{oscillating}" 131 | await self._async_send_command(msg) 132 | 133 | async def async_oscillate(self, oscillating: bool) -> None: 134 | """设置摇头状态.""" 135 | if self.is_on: 136 | speed = percentage_to_ordered_list_item(SPEED_LIST, self.percentage or 25) 137 | msg = f"{MSG_ON}#{speed}#{'1' if oscillating else '0'}" 138 | await self._async_send_command(msg) 139 | 140 | async def _async_send_command(self, command: str) -> None: 141 | """发送命令到巴法云.""" 142 | try: 143 | self._mqtt_client.publish(f"{self._topic}/set", command) 144 | self._parse_state(command) 145 | self.async_write_ha_state() 146 | await self.coordinator.async_request_refresh() 147 | except Exception as ex: 148 | _LOGGER.error("发送命令失败: %s", ex) 149 | 150 | @property 151 | def available(self) -> bool: 152 | """返回设备是否可用.""" 153 | return self.coordinator.data.get(self._topic, {}).get("online", True) 154 | 155 | def _handle_coordinator_update(self) -> None: 156 | """处理设备状态更新.""" 157 | device = self.coordinator.data.get(self._topic, {}) 158 | self._parse_state(device.get("state", "")) 159 | self.hass.loop.call_soon_threadsafe(self.async_write_ha_state) -------------------------------------------------------------------------------- /custom_components/bemfa_to_homeassistant/__init__.py: -------------------------------------------------------------------------------- 1 | """巴法云集成组件.""" 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | import logging 6 | from datetime import timedelta 7 | 8 | import async_timeout 9 | import requests 10 | import paho.mqtt.client as mqtt 11 | 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.exceptions import ConfigEntryNotReady 15 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 16 | 17 | from .const import ( 18 | DOMAIN, 19 | CONF_API_KEY, 20 | BEMFA_API_URL, 21 | DEFAULT_SCAN_INTERVAL, 22 | DEVICE_TYPES, 23 | PLATFORMS, 24 | MQTT_HOST, 25 | MQTT_PORT, 26 | MQTT_KEEPALIVE, 27 | TOPIC_PING, 28 | INTERVAL_PING_SEND, 29 | ) 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 34 | """设置巴法云集成.""" 35 | hass.data.setdefault(DOMAIN, {}) 36 | 37 | coordinator = BemfaDataUpdateCoordinator( 38 | hass, 39 | _LOGGER, 40 | entry.data[CONF_API_KEY], 41 | name="bemfa_devices", 42 | update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), 43 | ) 44 | 45 | try: 46 | await coordinator.async_config_entry_first_refresh() 47 | except Exception as err: 48 | raise ConfigEntryNotReady from err 49 | 50 | def on_connect(client, userdata, flags, rc): 51 | """MQTT连接回调.""" 52 | if rc == 0: 53 | client.subscribe(TOPIC_PING) 54 | for topic in coordinator.data: 55 | client.subscribe(topic) 56 | 57 | def on_message(client, userdata, msg): 58 | """MQTT消息回调.""" 59 | try: 60 | topic = msg.topic 61 | payload = msg.payload.decode() 62 | 63 | if topic == TOPIC_PING: 64 | coordinator._ping_lost = 0 65 | return 66 | 67 | if topic in coordinator.data: 68 | coordinator.data[topic].update({ 69 | "state": payload, 70 | "online": True 71 | }) 72 | hass.loop.call_soon_threadsafe( 73 | coordinator.async_set_updated_data, 74 | coordinator.data.copy() 75 | ) 76 | except Exception as err: 77 | _LOGGER.error("处理MQTT消息错误: %s", err) 78 | 79 | mqtt_client = mqtt.Client(client_id=entry.data[CONF_API_KEY]) 80 | mqtt_client.on_connect = on_connect 81 | mqtt_client.on_message = on_message 82 | 83 | try: 84 | mqtt_client.connect(MQTT_HOST, MQTT_PORT, MQTT_KEEPALIVE) 85 | mqtt_client.loop_start() 86 | 87 | async def send_ping(): 88 | while True: 89 | mqtt_client.publish(TOPIC_PING, "ping") 90 | await asyncio.sleep(INTERVAL_PING_SEND) 91 | 92 | hass.loop.create_task(send_ping()) 93 | 94 | except Exception as err: 95 | raise ConfigEntryNotReady("MQTT连接失败") from err 96 | 97 | hass.data[DOMAIN][entry.entry_id] = { 98 | "coordinator": coordinator, 99 | "mqtt_client": mqtt_client, 100 | } 101 | 102 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 103 | return True 104 | 105 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 106 | """卸载巴法云集成.""" 107 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 108 | if unload_ok: 109 | mqtt_client = hass.data[DOMAIN][entry.entry_id]["mqtt_client"] 110 | mqtt_client.loop_stop() 111 | mqtt_client.disconnect() 112 | hass.data[DOMAIN].pop(entry.entry_id) 113 | return unload_ok 114 | 115 | class BemfaDataUpdateCoordinator(DataUpdateCoordinator): 116 | """处理巴法云数据更新的类.""" 117 | 118 | def __init__( 119 | self, 120 | hass: HomeAssistant, 121 | logger: logging.Logger, 122 | api_key: str, 123 | name: str, 124 | update_interval: timedelta, 125 | ) -> None: 126 | """初始化.""" 127 | super().__init__( 128 | hass, 129 | logger, 130 | name=name, 131 | update_interval=update_interval, 132 | ) 133 | self.api_key = api_key 134 | self._devices = {} 135 | self._ping_lost = 0 136 | 137 | async def _async_update_data(self): 138 | """获取最新的设备数据.""" 139 | try: 140 | async with async_timeout.timeout(10): 141 | return await self._fetch_devices() 142 | except Exception as err: 143 | raise UpdateFailed(f"更新失败: {err}") from err 144 | 145 | async def _fetch_devices(self): 146 | """从巴法云获取设备列表.""" 147 | try: 148 | response = await self.hass.async_add_executor_job( 149 | self._get_devices 150 | ) 151 | 152 | devices = {} 153 | for device in response.get("data", []): 154 | topic = device.get("topic", "") 155 | device_type = self._get_device_type(topic) 156 | 157 | if device_type: 158 | devices[topic] = { 159 | "topic": topic, 160 | "name": device.get("name", topic), 161 | "type": device_type, 162 | "state": device.get("msg", ""), 163 | "online": True, 164 | } 165 | 166 | self._devices = devices 167 | return devices 168 | 169 | except requests.RequestException as err: 170 | raise UpdateFailed("无法连接到巴法云服务器") from err 171 | 172 | def _get_devices(self): 173 | """执行API请求获取设备列表.""" 174 | response = requests.get( 175 | BEMFA_API_URL, 176 | params={"uid": self.api_key, "type": "1"}, 177 | timeout=10, 178 | ) 179 | response.raise_for_status() 180 | return response.json() 181 | 182 | @staticmethod 183 | def _get_device_type(topic: str) -> str | None: 184 | """根据主题确定设备类型.""" 185 | if len(topic) >= 3: 186 | suffix = topic[-3:] 187 | if suffix in DEVICE_TYPES: 188 | return DEVICE_TYPES[suffix] 189 | 190 | topic_lower = topic.lower() 191 | if "fan" in topic_lower: 192 | return "fan" 193 | elif "switch" in topic_lower: 194 | return "switch" 195 | elif "light" in topic_lower: 196 | return "light" 197 | elif "sensor" in topic_lower: 198 | return "sensor" 199 | 200 | return None 201 | 202 | def get_device(self, topic: str): 203 | """获取指定主题的设备信息.""" 204 | return self._devices.get(topic) 205 | -------------------------------------------------------------------------------- /custom_components/bemfa_to_homeassistant/climate.py: -------------------------------------------------------------------------------- 1 | """巴法云空调设备平台.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import Any 6 | 7 | from homeassistant.components.climate import ( 8 | ClimateEntity, 9 | ClimateEntityFeature, 10 | HVACMode, 11 | FAN_AUTO, 12 | FAN_LOW, 13 | FAN_MEDIUM, 14 | FAN_HIGH, 15 | SWING_OFF, 16 | SWING_BOTH, 17 | SWING_HORIZONTAL, 18 | SWING_VERTICAL, 19 | ) 20 | from homeassistant.const import ( 21 | ATTR_TEMPERATURE, 22 | UnitOfTemperature, 23 | ) 24 | from homeassistant.config_entries import ConfigEntry 25 | from homeassistant.core import HomeAssistant 26 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 27 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 28 | 29 | from .const import ( 30 | DOMAIN, 31 | CONF_API_KEY, 32 | MSG_ON, 33 | MSG_OFF, 34 | MSG_SEPARATOR, 35 | ) 36 | from .helpers import BemfaBaseEntity 37 | 38 | _LOGGER = logging.getLogger(__name__) 39 | 40 | # 空调模式映射 41 | HVAC_MODES = { 42 | "1": HVACMode.AUTO, 43 | "2": HVACMode.COOL, 44 | "3": HVACMode.HEAT, 45 | "4": HVACMode.FAN_ONLY, 46 | "5": HVACMode.DRY, 47 | } 48 | HVAC_MODES_REVERSE = {v: k for k, v in HVAC_MODES.items()} 49 | 50 | # 风速映射 51 | FAN_MODES = { 52 | "0": FAN_AUTO, 53 | "1": FAN_LOW, 54 | "2": FAN_MEDIUM, 55 | "3": FAN_HIGH, 56 | } 57 | FAN_MODES_REVERSE = {v: k for k, v in FAN_MODES.items()} 58 | 59 | # 扫风映射 60 | SWING_MODES = { 61 | "0#0": SWING_OFF, 62 | "1#0": SWING_HORIZONTAL, 63 | "0#1": SWING_VERTICAL, 64 | "1#1": SWING_BOTH, 65 | } 66 | SWING_MODES_REVERSE = {v: k for k, v in SWING_MODES.items()} 67 | 68 | # 温度范围 69 | MIN_TEMP = 16 70 | MAX_TEMP = 32 71 | 72 | async def async_setup_entry( 73 | hass: HomeAssistant, 74 | entry: ConfigEntry, 75 | async_add_entities: AddEntitiesCallback, 76 | ) -> None: 77 | """设置巴法云空调设备.""" 78 | coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] 79 | mqtt_client = hass.data[DOMAIN][entry.entry_id]["mqtt_client"] 80 | 81 | entities = [ 82 | BemfaClimate(coordinator, mqtt_client, topic, entry) 83 | for topic, device in coordinator.data.items() 84 | if device["type"] == "climate" 85 | ] 86 | 87 | if entities: 88 | async_add_entities(entities) 89 | 90 | class BemfaClimate(CoordinatorEntity, BemfaBaseEntity, ClimateEntity): 91 | """巴法云空调设备.""" 92 | 93 | _attr_has_entity_name = True 94 | _attr_temperature_unit = UnitOfTemperature.CELSIUS 95 | _attr_hvac_modes = [HVACMode.OFF] + list(HVAC_MODES.values()) 96 | _attr_fan_modes = list(FAN_MODES.values()) 97 | _attr_swing_modes = list(SWING_MODES.values()) 98 | _attr_min_temp = MIN_TEMP 99 | _attr_max_temp = MAX_TEMP 100 | _attr_target_temperature_step = 1 101 | _attr_supported_features = ( 102 | ClimateEntityFeature.TARGET_TEMPERATURE | 103 | ClimateEntityFeature.FAN_MODE | 104 | ClimateEntityFeature.SWING_MODE 105 | ) 106 | 107 | def __init__(self, coordinator, mqtt_client, topic, entry): 108 | """初始化巴法云空调设备.""" 109 | super().__init__(coordinator) 110 | BemfaBaseEntity.__init__(self, topic, coordinator.data[topic]["name"]) 111 | 112 | self._topic = topic 113 | self._mqtt_client = mqtt_client 114 | self._api_key = entry.data[CONF_API_KEY] 115 | self._attr_unique_id = f"{DOMAIN}_{topic}_climate" 116 | self._attr_name = "空调" 117 | 118 | self._parse_state(coordinator.data[topic].get("state", "")) 119 | 120 | def _parse_state(self, state: str) -> None: 121 | """解析设备状态.""" 122 | if not state: 123 | self._attr_hvac_mode = HVACMode.OFF 124 | self._attr_target_temperature = MIN_TEMP 125 | self._attr_fan_mode = FAN_AUTO 126 | self._attr_swing_mode = SWING_OFF 127 | return 128 | 129 | parts = state.split(MSG_SEPARATOR) 130 | self._attr_hvac_mode = HVACMode.OFF if parts[0].lower() != MSG_ON else HVACMode.AUTO 131 | 132 | if len(parts) > 1 and self._attr_hvac_mode != HVACMode.OFF: 133 | self._attr_hvac_mode = HVAC_MODES.get(parts[1], HVACMode.AUTO) 134 | 135 | if len(parts) > 2 and self._attr_hvac_mode != HVACMode.OFF: 136 | try: 137 | self._attr_target_temperature = float(parts[2]) 138 | except (ValueError, IndexError): 139 | self._attr_target_temperature = MIN_TEMP 140 | 141 | if len(parts) > 3 and self._attr_hvac_mode != HVACMode.OFF: 142 | self._attr_fan_mode = FAN_MODES.get(parts[3], FAN_AUTO) 143 | 144 | if len(parts) > 5 and self._attr_hvac_mode != HVACMode.OFF: 145 | try: 146 | swing_key = f"{parts[4]}#{parts[5]}" 147 | self._attr_swing_mode = SWING_MODES.get(swing_key, SWING_OFF) 148 | except (ValueError, IndexError): 149 | self._attr_swing_mode = SWING_OFF 150 | 151 | async def async_set_temperature(self, **kwargs: Any) -> None: 152 | """设置温度.""" 153 | if ATTR_TEMPERATURE in kwargs: 154 | temperature = kwargs[ATTR_TEMPERATURE] 155 | await self._async_update_state(temperature=temperature) 156 | 157 | async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: 158 | """设置空调模式.""" 159 | if hvac_mode == HVACMode.OFF: 160 | await self._async_send_command(MSG_OFF) 161 | else: 162 | await self._async_update_state(hvac_mode=hvac_mode) 163 | 164 | async def async_set_fan_mode(self, fan_mode: str) -> None: 165 | """设置风速.""" 166 | await self._async_update_state(fan_mode=fan_mode) 167 | 168 | async def async_set_swing_mode(self, swing_mode: str) -> None: 169 | """设置扫风模式.""" 170 | await self._async_update_state(swing_mode=swing_mode) 171 | 172 | async def _async_update_state( 173 | self, 174 | temperature: float | None = None, 175 | hvac_mode: HVACMode | None = None, 176 | fan_mode: str | None = None, 177 | swing_mode: str | None = None, 178 | ) -> None: 179 | """更新设备状态.""" 180 | if hvac_mode is None: 181 | hvac_mode = self.hvac_mode 182 | if temperature is None: 183 | temperature = self.target_temperature 184 | if fan_mode is None: 185 | fan_mode = self.fan_mode 186 | if swing_mode is None: 187 | swing_mode = self.swing_mode 188 | 189 | if hvac_mode == HVACMode.OFF: 190 | await self._async_send_command(MSG_OFF) 191 | return 192 | 193 | mode = HVAC_MODES_REVERSE.get(hvac_mode, "1") 194 | temp = int(max(MIN_TEMP, min(MAX_TEMP, temperature))) 195 | fan = FAN_MODES_REVERSE.get(fan_mode, "0") 196 | swing = SWING_MODES_REVERSE.get(swing_mode, "0#0") 197 | 198 | msg = f"{MSG_ON}#{mode}#{temp}#{fan}#{swing}" 199 | await self._async_send_command(msg) 200 | 201 | async def _async_send_command(self, command: str) -> None: 202 | """发送命令到巴法云.""" 203 | try: 204 | self._mqtt_client.publish(f"{self._topic}/set", command) 205 | self._parse_state(command) 206 | self.async_write_ha_state() 207 | await self.coordinator.async_request_refresh() 208 | except Exception as ex: 209 | _LOGGER.error("发送命令失败: %s", ex) 210 | 211 | @property 212 | def available(self) -> bool: 213 | """返回设备是否可用.""" 214 | return self.coordinator.data.get(self._topic, {}).get("online", True) 215 | 216 | def _handle_coordinator_update(self) -> None: 217 | """处理设备状态更新.""" 218 | device = self.coordinator.data.get(self._topic, {}) 219 | self._parse_state(device.get("state", "")) 220 | self.hass.loop.call_soon_threadsafe(self.async_write_ha_state) -------------------------------------------------------------------------------- /custom_components/bemfa_to_homeassistant/sensor.py: -------------------------------------------------------------------------------- 1 | """巴法云传感器设备平台.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import Any 6 | 7 | from homeassistant.components.sensor import ( 8 | SensorEntity, 9 | SensorDeviceClass, 10 | SensorStateClass, 11 | ) 12 | from homeassistant.components.binary_sensor import ( 13 | BinarySensorEntity, 14 | BinarySensorDeviceClass, 15 | ) 16 | from homeassistant.config_entries import ConfigEntry 17 | from homeassistant.const import ( 18 | PERCENTAGE, 19 | LIGHT_LUX, 20 | CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 21 | UnitOfTemperature, 22 | ) 23 | from homeassistant.core import HomeAssistant 24 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 25 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 26 | 27 | from .const import ( 28 | DOMAIN, 29 | CONF_API_KEY, 30 | MSG_SEPARATOR, 31 | MSG_ON, 32 | ) 33 | from .helpers import BemfaBaseEntity 34 | 35 | _LOGGER = logging.getLogger(__name__) 36 | 37 | SENSOR_TYPES = { 38 | "temperature": { 39 | "name": "温度", 40 | "device_class": SensorDeviceClass.TEMPERATURE, 41 | "state_class": SensorStateClass.MEASUREMENT, 42 | "unit": UnitOfTemperature.CELSIUS, 43 | "index": 1, 44 | }, 45 | "humidity": { 46 | "name": "湿度", 47 | "device_class": SensorDeviceClass.HUMIDITY, 48 | "state_class": SensorStateClass.MEASUREMENT, 49 | "unit": PERCENTAGE, 50 | "index": 2, 51 | }, 52 | "switch": { 53 | "name": "开关", 54 | "device_class": BinarySensorDeviceClass.POWER, 55 | "index": 3, 56 | }, 57 | "illuminance": { 58 | "name": "光照", 59 | "device_class": SensorDeviceClass.ILLUMINANCE, 60 | "state_class": SensorStateClass.MEASUREMENT, 61 | "unit": LIGHT_LUX, 62 | "index": 4, 63 | }, 64 | "pm25": { 65 | "name": "PM2.5", 66 | "device_class": SensorDeviceClass.PM25, 67 | "state_class": SensorStateClass.MEASUREMENT, 68 | "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 69 | "index": 5, 70 | }, 71 | "heart_rate": { 72 | "name": "心率", 73 | "device_class": None, 74 | "state_class": SensorStateClass.MEASUREMENT, 75 | "unit": "bpm", 76 | "index": 6, 77 | }, 78 | } 79 | 80 | async def async_setup_entry( 81 | hass: HomeAssistant, 82 | entry: ConfigEntry, 83 | async_add_entities: AddEntitiesCallback, 84 | ) -> None: 85 | """设置巴法云传感器设备.""" 86 | coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] 87 | mqtt_client = hass.data[DOMAIN][entry.entry_id]["mqtt_client"] 88 | 89 | entities = [] 90 | for topic, device in coordinator.data.items(): 91 | if device["type"] == "sensor": 92 | state = device.get("state", "") 93 | parts = state.split(MSG_SEPARATOR) if state else [] 94 | 95 | entities.append( 96 | BemfaSensor(coordinator, mqtt_client, topic, entry, "temperature", SENSOR_TYPES["temperature"]) 97 | ) 98 | 99 | if len(parts) > 2: 100 | entities.append( 101 | BemfaSensor(coordinator, mqtt_client, topic, entry, "humidity", SENSOR_TYPES["humidity"]) 102 | ) 103 | 104 | if len(parts) > 3: 105 | entities.append( 106 | BemfaBinarySensor(coordinator, mqtt_client, topic, entry, "switch", SENSOR_TYPES["switch"]) 107 | ) 108 | 109 | if len(parts) > 4: 110 | entities.append( 111 | BemfaSensor(coordinator, mqtt_client, topic, entry, "illuminance", SENSOR_TYPES["illuminance"]) 112 | ) 113 | 114 | if len(parts) > 5: 115 | entities.append( 116 | BemfaSensor(coordinator, mqtt_client, topic, entry, "pm25", SENSOR_TYPES["pm25"]) 117 | ) 118 | 119 | if len(parts) > 6: 120 | entities.append( 121 | BemfaSensor(coordinator, mqtt_client, topic, entry, "heart_rate", SENSOR_TYPES["heart_rate"]) 122 | ) 123 | 124 | if entities: 125 | async_add_entities(entities) 126 | 127 | class BemfaSensor(CoordinatorEntity, BemfaBaseEntity, SensorEntity): 128 | """巴法云传感器设备.""" 129 | 130 | def __init__( 131 | self, 132 | coordinator, 133 | mqtt_client, 134 | topic, 135 | entry, 136 | sensor_type: str, 137 | config: dict, 138 | ): 139 | """初始化巴法云传感器设备.""" 140 | super().__init__(coordinator) 141 | BemfaBaseEntity.__init__(self, topic, coordinator.data[topic]["name"]) 142 | 143 | self._topic = topic 144 | self._mqtt_client = mqtt_client 145 | self._api_key = entry.data[CONF_API_KEY] 146 | self._sensor_type = sensor_type 147 | self._config = config 148 | 149 | self._attr_unique_id = f"{DOMAIN}_{topic}_{sensor_type}" 150 | self._attr_name = config["name"] 151 | self._attr_native_unit_of_measurement = config.get("unit") 152 | self._attr_device_class = config.get("device_class") 153 | self._attr_state_class = config.get("state_class") 154 | 155 | self._parse_state(coordinator.data[topic].get("state", "")) 156 | 157 | def _parse_state(self, state: str) -> None: 158 | """解析设备状态.""" 159 | if not state: 160 | self._attr_native_value = None 161 | return 162 | 163 | parts = state.split(MSG_SEPARATOR) 164 | index = self._config["index"] 165 | 166 | if len(parts) > index: 167 | try: 168 | value = parts[index].strip() 169 | if value: 170 | self._attr_native_value = float(value) 171 | else: 172 | self._attr_native_value = None 173 | except (ValueError, IndexError): 174 | self._attr_native_value = None 175 | else: 176 | self._attr_native_value = None 177 | 178 | @property 179 | def available(self) -> bool: 180 | """返回设备是否可用.""" 181 | return self.coordinator.data.get(self._topic, {}).get("online", True) 182 | 183 | def _handle_coordinator_update(self) -> None: 184 | """处理设备状态更新.""" 185 | device = self.coordinator.data.get(self._topic, {}) 186 | self._parse_state(device.get("state", "")) 187 | self.hass.loop.call_soon_threadsafe(self.async_write_ha_state) 188 | 189 | class BemfaBinarySensor(CoordinatorEntity, BemfaBaseEntity, BinarySensorEntity): 190 | """巴法云二进制传感器设备.""" 191 | 192 | def __init__( 193 | self, 194 | coordinator, 195 | mqtt_client, 196 | topic, 197 | entry, 198 | sensor_type: str, 199 | config: dict, 200 | ): 201 | """初始化巴法云二进制传感器设备.""" 202 | super().__init__(coordinator) 203 | BemfaBaseEntity.__init__(self, topic, coordinator.data[topic]["name"]) 204 | 205 | self._topic = topic 206 | self._mqtt_client = mqtt_client 207 | self._api_key = entry.data[CONF_API_KEY] 208 | self._sensor_type = sensor_type 209 | self._config = config 210 | 211 | self._attr_unique_id = f"{DOMAIN}_{topic}_{sensor_type}" 212 | self._attr_name = config["name"] 213 | self._attr_device_class = config.get("device_class") 214 | 215 | self._parse_state(coordinator.data[topic].get("state", "")) 216 | 217 | def _parse_state(self, state: str) -> None: 218 | """解析设备状态.""" 219 | if not state: 220 | self._attr_is_on = None 221 | return 222 | 223 | parts = state.split(MSG_SEPARATOR) 224 | index = self._config["index"] 225 | 226 | if len(parts) > index: 227 | value = parts[index].strip().lower() 228 | self._attr_is_on = bool(value) and value == MSG_ON 229 | else: 230 | self._attr_is_on = None 231 | 232 | @property 233 | def available(self) -> bool: 234 | """返回设备是否可用.""" 235 | return self.coordinator.data.get(self._topic, {}).get("online", True) 236 | 237 | def _handle_coordinator_update(self) -> None: 238 | """处理设备状态更新.""" 239 | device = self.coordinator.data.get(self._topic, {}) 240 | self._parse_state(device.get("state", "")) 241 | self.hass.loop.call_soon_threadsafe(self.async_write_ha_state) --------------------------------------------------------------------------------