├── 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 | 
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)
--------------------------------------------------------------------------------