├── LICENSE ├── README.md ├── README_Hans.md ├── custom_components └── peacefair_energy │ ├── __init__.py │ ├── config_flow.py │ ├── const.py │ ├── manifest.json │ ├── modbus.py │ ├── sensor.py │ ├── services.yaml │ └── translations │ ├── en.json │ └── zh-Hans.json └── hacs.json /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 georgezhao2010 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Peacefair Energy Monitor 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) 4 | 5 | English | [简体中文](https://github.com/georgezhao2010/peacefair_energy/blob/main/README_Hans.md) 6 | 7 | The Home Assistant Integration for Peacefair AC Communication Module PZEM-004T, It supports new energy function of Home Assistant 2021.8.x. 8 | 9 | It has supports access PZEM-004T module via ModbusRTU over UDP/TCP, and does NOT supports serial port. 10 | -------------------------------------------------------------------------------- /README_Hans.md: -------------------------------------------------------------------------------- 1 | # Peacefair Energy Monitor 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) 4 | 5 | [English](https://github.com/georgezhao2010/peacefair_energy/blob/main/README.md) | 简体中文 6 | 7 | 使用培正PZEM-004T交流通讯盒进行用电信息采集的Home Assistant集成,支持Home Assistant 2021.8.X后新增的能源功能。 8 | 9 | 支持通过ModbusRTU over UDP/TCP访问PZEM-004T,不支持串口访问方式。 10 | 11 | 12 | # 警告 13 | 14 | **以下操作仅适于有一定强电电路知识的人,涉及强电操作,注意人身安全。** 15 | 16 | 17 | # 硬件准备 18 | 19 | ## 用电信息采集 20 | 21 | 用电信息采集采用的是培正PZEM-004T,论坛中已有很多解决方案,本方案中采用的是100A使用互感器非接触采集的方案。 22 | 非接触采集不用接触强电接线,安全性较好。 23 | 24 | 25 | ## 信息传输 26 | 27 | PZEM-004T使用TTL进行通讯,但大老远的拉根USB-TTL线到HA主机,显然不现实,所以这里采用的是WiFi-TTL无线传输模块。 28 | 因为PZEM-004T的TTL是5V供电,因此没有直接采用3.3V的ESP-01(S)模块,而是使用了一款基于ESP-M2的DT-06 WiFi-TTL无线传输模块,自带透传固件,5V供电,省掉了5V-3.3V降压模块。当然你可以根据喜好自主选择无线传输模块。 29 | 30 | 如何配置透传模块,透传模块使用的端口、协议,要记清楚,后续配置要用。 31 | 32 | 33 | ## 接线图 34 | 35 | ![IMG](https://user-images.githubusercontent.com/27534713/130238853-da93f5c7-105d-4170-be55-89ed83e9f06f.png) 36 | 37 | 38 | ## 接线实拍 39 | 40 | 照片中是拆掉了弱电箱的门,互感器直接套在入户主线的火线上,从插座的空开下接了一个带USB的小插座,用于给PZEM-004T提供220V/5V供电。看起来乱七八糟,外边挂一幅画就全挡住了。 41 | 42 | ![IMG](https://user-images.githubusercontent.com/27534713/130238749-2751d491-259b-4974-b838-0bdb550970da.jpg) 43 | 44 | 45 | 46 | PZEM-004T的RX口和DT-06的TX口之间可以考虑串接1K左右电阻,防止PZEM-004T的RX灯常亮 47 | 48 | ![IMG](https://user-images.githubusercontent.com/27534713/178296051-b32f95df-d317-4697-95ec-6b4754cbf30f.png) 49 | 50 | 51 | # 集成安装 52 | 53 | 使用HACS自定义存储库安装,或者从[Latest release](https://github/georgezhao2010/peacefair_energy/release/latest)下载最新的Release版本,将其中的`custom_components/peacefair_energy`下所有文件放到`/custom_components/peacefair_energy`中,重新启动Home Assistant。 54 | 55 | 56 | # 配置 57 | 58 | ## 安装 59 | 在Home Assistant的集成界面,点击添加集成,搜索”Peacefair Energy Monitor”进行添加。需要填写的数据包括: 60 | - 透传模块的IP地址 61 | - 透传模块的端口 62 | - 协议(TCP或UDP) 63 | - 模块从站地址(一般为1) 64 | 65 | 66 | ## 选项 67 | 选项是采集间隔,默认为15秒钟采集一次,可根据需要自行调整。 68 | 69 | 70 | # 特性 71 | - 支持Home Assistant之后的能源面板 72 | - 自动记录日、周、月、年度的实时用电量 73 | - 提供昨日、上周、上月、去年的历史用电量 74 | 75 | ## 误差 76 | 根据长期测试,该集成的月度统计数据与国网电力的数据误差低于3%。 77 | 78 | 79 | ## 实时用电信息 80 | 实时信息包含以下传感器 81 | | 传感器名称 | 默认名称 |含义 | 82 | | ---- | ---- | ---- | 83 | | sensor.IP_energy | Energy |总用电量 | 84 | | sensor.IP_voltage | Voltage |当前电压 | 85 | | sensor.IP_current | Current |当前电流 | 86 | | sensor.IP_power | Power |当前功率 | 87 | | sensor.IP_frequency | Power Frequency |交流频率 | 88 | | sensor.IP_power_factor | Power Factor |当前功率因数 | 89 | 90 | 91 | ## 统计信息 92 | 统计信息包含以下传感器 93 | | 传感器名称 | 默认名称 |含义 | 94 | | ---- | ---- | ---- | 95 | | sensor.IP_day_real | Energy Consumption Today |今日用电量 | 96 | | sensor.IP_day_history | Energy Consumption Yesterday |昨日用电量 | 97 | | sensor.IP_week_real | Energy Consumption This Week |本周用电量 | 98 | | sensor.IP_week_history | Energy Consumption Last Week | 上周用电量 | 99 | | sensor.IP_month_real | Energy Consumption This Month | 本月用电量 | 100 | | sensor.IP_month_history | Energy Consumption Last Month | 上月用电量 | 101 | | sensor.IP_year_real | Energy Consumption This Year | 今年用电量 | 102 | | sensor.IP_year_history | Energy Consumption Last Year | 去年用电量 | 103 | 104 | 105 | ## 服务 106 | 包含一个服务 107 | 108 | ### peacefair_energy.reset_energy 109 | 110 | 作用为重置总用电量 111 | 112 | | 参数 | 描述 | 示例 | 113 | | ---- | ---- | ---- | 114 | | entity_id | 要重置的总用电量实体 | sensor.IP_energy | 115 | 116 | *注意:重置总用电量不会影响各实时传感器及统计信息传感器的数值* 117 | 118 | 119 | # Home Asssitant的能源 120 | 在Home Assistant 2021.8.X中新增的能源功能,可以使用集成中的"总用电量"传感器作为能源消耗的统计依据。 121 | 如果你是国网北京电力的用户,可以使用[bj_sgcc_energy](https://github.com/georgezhao2010/bj_sgcc_energy)作为电费单价实体。 122 | 不是国网北京电力的用户也没关系,可以使用本集成中的本月用电量、今年用电量,根据当地的用电收费政策,使用模版计算出用电单价来。 123 | ![IMG](https://user-images.githubusercontent.com/27534713/130241300-1307c9ff-0f10-47f0-bd62-c601a99a0cd9.png) 124 | 125 | 126 | # 调试 127 | 要打开调试日志输出,在configuration.yaml中做如下配置 128 | ``` 129 | logger: 130 | default: warn 131 | logs: 132 | custom_components.peacefair_energy: debug 133 | ``` 134 | 135 | # 传感器信息显示 136 | 以下为示例 137 | 138 | ![IMG](https://user-images.githubusercontent.com/27534713/130247060-016a8cae-c51e-49ef-9ba5-8a200e835dc9.png) 139 | ![IMG](https://user-images.githubusercontent.com/27534713/130249630-5546f8ad-8d98-46b9-8d98-9623dcfe1ff5.png) 140 | ![IMG](https://user-images.githubusercontent.com/27534713/130247142-90c200d6-d2f5-4294-83bd-4e1b48031571.png) 141 | 142 | -------------------------------------------------------------------------------- /custom_components/peacefair_energy/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import homeassistant.helpers.config_validation as cv 3 | import voluptuous as vol 4 | import os 5 | from datetime import timedelta 6 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 7 | from homeassistant.core import HomeAssistant 8 | from .const import( 9 | DOMAIN, 10 | COORDINATOR, 11 | DEFAULT_SCAN_INTERVAL, 12 | ENERGY_SENSOR, 13 | UN_SUBDISCRIPT, 14 | DEVICES, 15 | PROTOCOLS, 16 | STORAGE_PATH 17 | ) 18 | 19 | from homeassistant.const import ( 20 | CONF_PROTOCOL, 21 | CONF_SCAN_INTERVAL, 22 | CONF_HOST, 23 | CONF_PORT, 24 | CONF_SLAVE, 25 | DEVICE_CLASS_ENERGY 26 | ) 27 | 28 | from homeassistant.const import ATTR_ENTITY_ID 29 | 30 | from .modbus import ModbusHub 31 | 32 | SERVICE_RESET_ENERGY = "reset_energy" 33 | 34 | RESET_ENERGY_SCHEMA = vol.Schema( 35 | { 36 | vol.Required(ATTR_ENTITY_ID): cv.entity_id 37 | } 38 | ) 39 | 40 | _LOGGER = logging.getLogger(__name__) 41 | 42 | 43 | async def update_listener(hass, config_entry): 44 | scan_interval = config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) 45 | coordinator = hass.data[config_entry.entry_id][COORDINATOR] 46 | coordinator.update_interval = timedelta(seconds=scan_interval) 47 | 48 | 49 | async def async_setup(hass: HomeAssistant, hass_config: dict): 50 | hass.data.setdefault(DOMAIN, {}) 51 | return True 52 | 53 | 54 | async def async_setup_entry(hass: HomeAssistant, config_entry): 55 | config = config_entry.data 56 | protocol = PROTOCOLS[config[CONF_PROTOCOL]] 57 | _LOGGER.debug(f"protocol={protocol}") 58 | host = config[CONF_HOST] 59 | port = config[CONF_PORT] 60 | slave = config[CONF_SLAVE] 61 | scan_interval = config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) 62 | if DOMAIN not in hass.data: 63 | hass.data[DOMAIN] = {} 64 | if DEVICES not in hass.data[DOMAIN]: 65 | hass.data[DOMAIN][DEVICES] = [] 66 | hass.data[DOMAIN][DEVICES].append(host) 67 | if config_entry.entry_id not in hass.data: 68 | hass.data[config_entry.entry_id] = {} 69 | coordinator = PeacefairCoordinator(hass, protocol, host, port, slave, scan_interval) 70 | hass.data[config_entry.entry_id][COORDINATOR] = coordinator 71 | await coordinator.async_config_entry_first_refresh() 72 | hass.async_create_task(hass.config_entries.async_forward_entry_setup( 73 | config_entry, "sensor")) 74 | hass.data[config_entry.entry_id][UN_SUBDISCRIPT] = config_entry.add_update_listener(update_listener) 75 | 76 | def service_handle(service): 77 | entity_id = service.data[ATTR_ENTITY_ID] 78 | energy_sensor = next( 79 | (sensor for sensor in hass.data[DOMAIN][ENERGY_SENSOR] if sensor.entity_id == entity_id), 80 | None, 81 | ) 82 | if energy_sensor is None: 83 | return 84 | 85 | if service.service == SERVICE_RESET_ENERGY: 86 | coordinator = hass.data[config_entry.entry_id][COORDINATOR] 87 | if coordinator is not None: 88 | coordinator.reset_energy() 89 | energy_sensor.reset() 90 | 91 | hass.services.async_register( 92 | DOMAIN, 93 | SERVICE_RESET_ENERGY, 94 | service_handle, 95 | schema=RESET_ENERGY_SCHEMA, 96 | ) 97 | 98 | return True 99 | 100 | 101 | async def async_unload_entry(hass: HomeAssistant, config_entry): 102 | 103 | await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") 104 | 105 | host = config_entry.data[CONF_HOST] 106 | host = host.replace(".", "_") 107 | energy_sensor = next( 108 | (sensor for sensor in hass.data[DOMAIN][ENERGY_SENSOR] if sensor.entity_id == f"{host}_{DEVICE_CLASS_ENERGY}"), 109 | None, 110 | ) 111 | if energy_sensor is not None: 112 | hass.data[DOMAIN][ENERGY_SENSOR].pop(energy_sensor) 113 | unsub = hass.data[config_entry.entry_id][UN_SUBDISCRIPT] 114 | if unsub is not None: 115 | unsub() 116 | hass.data.pop(config_entry.entry_id) 117 | storage_path = hass.config.path(f"{STORAGE_PATH}") 118 | record_file = hass.config.path(f"{STORAGE_PATH}/{config_entry.entry_id}_state.json") 119 | reset_file = hass.config.path(f"{STORAGE_PATH}/{DOMAIN}_reset.json") 120 | if os.path.exists(record_file): 121 | os.remove(record_file) 122 | if os.path.exists(reset_file): 123 | os.remove(reset_file) 124 | if len(os.listdir(storage_path)) == 0: 125 | os.rmdir(storage_path) 126 | return True 127 | 128 | 129 | class PeacefairCoordinator(DataUpdateCoordinator): 130 | def __init__(self, hass, protocol, host, port, slave, scan_interval): 131 | super().__init__( 132 | hass, 133 | _LOGGER, 134 | name=DOMAIN, 135 | update_interval=timedelta(seconds=scan_interval) 136 | ) 137 | self._updates = None 138 | self._hass = hass 139 | self._host = host 140 | self._hub = ModbusHub(protocol, host, port, slave) 141 | 142 | @property 143 | def host(self): 144 | return self._host 145 | 146 | def reset_energy(self): 147 | self._hub.reset_energy() 148 | self.data[DEVICE_CLASS_ENERGY] = 0.0 149 | 150 | def set_update(self, update): 151 | self._updates = update 152 | 153 | async def _async_update_data(self): 154 | data = self.data if self.data is not None else {} 155 | data_update = self._hub.info_gather() 156 | if len(data_update) > 0: 157 | data = data_update 158 | _LOGGER.debug(f"Got Data {data}") 159 | if self._updates is not None: 160 | self._updates() 161 | return data -------------------------------------------------------------------------------- /custom_components/peacefair_energy/config_flow.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from homeassistant.core import callback 3 | from homeassistant import config_entries 4 | import voluptuous as vol 5 | 6 | from .const import ( 7 | DOMAIN, 8 | DEFAULT_SLAVE, 9 | DEFAULT_PROTOCOL, 10 | DEFAULT_PORT, 11 | DEFAULT_SCAN_INTERVAL, 12 | DEVICES, 13 | PROTOCOLS 14 | ) 15 | 16 | from homeassistant.const import ( 17 | CONF_PROTOCOL, 18 | CONF_SCAN_INTERVAL, 19 | CONF_HOST, 20 | CONF_PORT, 21 | CONF_SLAVE 22 | ) 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | 27 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 28 | async def async_step_user(self, user_input=None, error=None): 29 | 30 | if user_input is not None: 31 | if DOMAIN in self.hass.data and DEVICES in self.hass.data[DOMAIN] and \ 32 | user_input[CONF_HOST] in self.hass.data[DOMAIN][DEVICES]: 33 | return await self.async_step_user(error="device_exist") 34 | else: 35 | return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) 36 | return self.async_show_form( 37 | step_id="user", 38 | data_schema=vol.Schema({ 39 | vol.Required(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): vol.In(PROTOCOLS.keys()), 40 | vol.Required(CONF_HOST): str, 41 | vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int), 42 | vol.Required(CONF_SLAVE, default=DEFAULT_SLAVE): vol.Coerce(int) 43 | }), 44 | errors={"base": error} if error else None 45 | ) 46 | @staticmethod 47 | @callback 48 | def async_get_options_flow(config_entry): 49 | return OptionsFlowHandler(config_entry) 50 | 51 | 52 | class OptionsFlowHandler(config_entries.OptionsFlow): 53 | 54 | def __init__(self, config_entry: config_entries.ConfigEntry): 55 | self.config_entry = config_entry 56 | 57 | async def async_step_init(self, user_input=None): 58 | 59 | if user_input is not None: 60 | return self.async_create_entry(title="", data=user_input) 61 | 62 | scan_interval = self.config_entry.options.get( 63 | CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL 64 | ) 65 | 66 | return self.async_show_form( 67 | step_id="init", 68 | data_schema=vol.Schema({ 69 | vol.Optional( 70 | CONF_SCAN_INTERVAL, 71 | default=scan_interval, 72 | ): vol.All(vol.Coerce(int)), 73 | }), 74 | ) 75 | -------------------------------------------------------------------------------- /custom_components/peacefair_energy/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = "peacefair_energy" 2 | DEFAULT_SCAN_INTERVAL = 15 3 | DEFAULT_SLAVE = 1 4 | DEFAULT_PROTOCOL = "ModbusRTU Over UDP/IP" 5 | DEFAULT_PORT = 9000 6 | COORDINATOR = "coordinator" 7 | ENERGY_SENSOR = "energy_sensor" 8 | UN_SUBDISCRIPT = "un_subdiscript" 9 | DEVICE_CLASS_FREQUENCY = "frequency" 10 | DEVICES = "devices" 11 | VERSION = "0.7.0" 12 | GATHER_TIME = "gather_time" 13 | PROTOCOLS = { 14 | "ModbusRTU Over UDP/IP": "rtuoverudp", 15 | "ModbusRTU Over TCP/IP": "rtuovertcp" 16 | } 17 | STORAGE_PATH = f".storage/{DOMAIN}" -------------------------------------------------------------------------------- /custom_components/peacefair_energy/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "peacefair_energy", 3 | "name": "Peacefair Energy Monitor", 4 | "version": "v0.7.4", 5 | "config_flow": true, 6 | "documentation": "https://github.com/georgezhao2010/peacefair_energy", 7 | "issue_tracker": "https://github.com/georgezhao2010/peacefair_energy/issue", 8 | "iot_class": "local_polling", 9 | "dependencies": [], 10 | "codeowners": ["@georgezhao2010"], 11 | "requirements": [] 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/peacefair_energy/modbus.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pymodbus.client import ModbusTcpClient, ModbusUdpClient 3 | from pymodbus.transaction import ModbusRtuFramer, ModbusIOException 4 | from pymodbus.pdu import ModbusRequest 5 | import threading 6 | try: 7 | from homeassistant.const import ( 8 | DEVICE_CLASS_VOLTAGE , 9 | DEVICE_CLASS_CURRENT, 10 | DEVICE_CLASS_POWER, 11 | DEVICE_CLASS_ENERGY, 12 | DEVICE_CLASS_POWER_FACTOR 13 | ) 14 | except ImportError: 15 | DEVICE_CLASS_VOLTAGE = "voltage" 16 | DEVICE_CLASS_CURRENT = "current" 17 | DEVICE_CLASS_POWER = "power" 18 | DEVICE_CLASS_ENERGY = "energy" 19 | DEVICE_CLASS_POWER_FACTOR = "power_factor" 20 | 21 | from .const import( 22 | DEVICE_CLASS_FREQUENCY 23 | ) 24 | 25 | HPG_SENSOR_TYPES = [ 26 | DEVICE_CLASS_VOLTAGE , 27 | DEVICE_CLASS_CURRENT, 28 | DEVICE_CLASS_POWER, 29 | DEVICE_CLASS_ENERGY , 30 | DEVICE_CLASS_POWER_FACTOR, 31 | DEVICE_CLASS_FREQUENCY 32 | ] 33 | 34 | _LOGGER = logging.getLogger(__name__) 35 | 36 | class ModbusResetEnergyRequest(ModbusRequest): 37 | _rtu_frame_size = 4 38 | function_code = 0x42 39 | def __init__(self, **kwargs): 40 | ModbusRequest.__init__(self, **kwargs) 41 | 42 | def encode(self): 43 | return b'' 44 | 45 | def get_response_pdu_size(self): 46 | return 4 47 | 48 | def __str__(self): 49 | return "ModbusResetEnergyRequest" 50 | 51 | class ModbusHub: 52 | def __init__(self, protocol, host, port, slave): 53 | self._lock = threading.Lock() 54 | self._slave = slave 55 | if(protocol == "rtuovertcp"): 56 | self._client = ModbusTcpClient( 57 | host = host, 58 | port = port, 59 | framer = ModbusRtuFramer, 60 | timeout = 2, 61 | retry_on_empty = True, 62 | retry_on_invalid = False 63 | ) 64 | elif (protocol == "rtuoverudp"): 65 | self._client = ModbusUdpClient( 66 | host = host, 67 | port = port, 68 | framer = ModbusRtuFramer, 69 | timeout = 2, 70 | retry_on_empty = False, 71 | retry_on_invalid = False 72 | ) 73 | 74 | def connect(self): 75 | with self._lock: 76 | self._client.connect() 77 | 78 | def close(self): 79 | with self._lock: 80 | self._client.close() 81 | 82 | def read_holding_register(self): 83 | pass 84 | 85 | def read_input_registers(self, address, count): 86 | with self._lock: 87 | kwargs = {"slave": self._slave} 88 | return self._client.read_input_registers(address, count, **kwargs) 89 | 90 | def reset_energy(self): 91 | with self._lock: 92 | kwargs = {"slave": self._slave} 93 | request = ModbusResetEnergyRequest(**kwargs) 94 | self._client.execute(request) 95 | 96 | def info_gather(self): 97 | data = {} 98 | try: 99 | result = self.read_input_registers(0, 9) 100 | if result is not None and type(result) is not ModbusIOException \ 101 | and result.registers is not None and len(result.registers) == 9: 102 | data[DEVICE_CLASS_VOLTAGE] = result.registers[0] / 10 103 | data[DEVICE_CLASS_CURRENT] = ((result.registers[2] << 16) + result.registers[1]) / 1000 104 | data[DEVICE_CLASS_POWER] = ((result.registers[4] << 16) + result.registers[3]) / 10 105 | data[DEVICE_CLASS_ENERGY] = ((result.registers[6] << 16) + result.registers[5]) / 1000 106 | data[DEVICE_CLASS_FREQUENCY] = result.registers[7] / 10 107 | data[DEVICE_CLASS_POWER_FACTOR] = result.registers[8] / 100 108 | else: 109 | _LOGGER.debug(f"Error in gathering, timed out") 110 | except Exception as e: 111 | _LOGGER.error(f"Error in gathering, {e}") 112 | return data 113 | -------------------------------------------------------------------------------- /custom_components/peacefair_energy/sensor.py: -------------------------------------------------------------------------------- 1 | from homeassistant.const import ( 2 | STATE_UNKNOWN, 3 | DEVICE_CLASS_VOLTAGE, 4 | DEVICE_CLASS_CURRENT, 5 | DEVICE_CLASS_POWER, 6 | DEVICE_CLASS_ENERGY, 7 | DEVICE_CLASS_POWER_FACTOR, 8 | ELECTRIC_POTENTIAL_VOLT, 9 | ELECTRIC_CURRENT_AMPERE, 10 | POWER_WATT, 11 | ENERGY_KILO_WATT_HOUR, 12 | FREQUENCY_HERTZ 13 | ) 14 | 15 | from .const import ( 16 | DOMAIN, 17 | COORDINATOR, 18 | ENERGY_SENSOR, 19 | DEVICE_CLASS_FREQUENCY, 20 | VERSION, 21 | STORAGE_PATH 22 | ) 23 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 24 | from homeassistant.helpers.entity import Entity 25 | from homeassistant.util.json import load_json 26 | from homeassistant.helpers.json import save_json 27 | from typing import final, Final 28 | import time 29 | import logging 30 | import os 31 | import datetime 32 | 33 | _LOGGER = logging.getLogger(__name__) 34 | 35 | 36 | HPG_SENSORS = { 37 | DEVICE_CLASS_VOLTAGE: { 38 | "name": "Voltage", 39 | "unit": ELECTRIC_POTENTIAL_VOLT, 40 | "state_class": "measurement" 41 | }, 42 | DEVICE_CLASS_CURRENT: { 43 | "name": "Current", 44 | "unit": ELECTRIC_CURRENT_AMPERE, 45 | "state_class": "measurement" 46 | }, 47 | DEVICE_CLASS_POWER: { 48 | "name": "Power", 49 | "unit": POWER_WATT, 50 | "state_class": "measurement" 51 | }, 52 | DEVICE_CLASS_ENERGY: { 53 | "name": "Energy", 54 | "unit": ENERGY_KILO_WATT_HOUR, 55 | "state_class": "total_increasing" 56 | }, 57 | DEVICE_CLASS_POWER_FACTOR: { 58 | "name": "Power Factor", 59 | "state_class": "measurement" 60 | }, 61 | DEVICE_CLASS_FREQUENCY: { 62 | "name": "Power Frequency", 63 | "unit": FREQUENCY_HERTZ, 64 | "icon": "hass:current-ac", 65 | "state_class": "measurement" 66 | }, 67 | } 68 | 69 | HISTORY_YEAR = "year" 70 | HISTORY_MONTH = "month" 71 | HISTORY_WEEK = "week" 72 | HISTORY_DAY = "day" 73 | 74 | HISTORIES = { 75 | HISTORY_YEAR: { 76 | "history_name": "Energy Consumption Last Year", 77 | "real_name": "Energy Consumption This Year", 78 | }, 79 | HISTORY_MONTH: { 80 | "history_name": "Energy Consumption Last Month", 81 | "real_name": "Energy Consumption This Month", 82 | }, 83 | HISTORY_WEEK: { 84 | "history_name": "Energy Consumption Last Week", 85 | "real_name": "Energy Consumption This Week", 86 | }, 87 | HISTORY_DAY:{ 88 | "history_name": "Energy Consumption Yesterday", 89 | "real_name": "Energy Consumption Today", 90 | } 91 | } 92 | ATTR_LAST_RESET: Final = "last_reset" 93 | ATTR_STATE_CLASS: Final = "state_class" 94 | 95 | 96 | async def async_setup_entry(hass, config_entry, async_add_entities): 97 | sensors = [] 98 | coordinator = hass.data[config_entry.entry_id][COORDINATOR] 99 | ident = coordinator.host.replace(".", "_") 100 | updates = {} 101 | os.makedirs(hass.config.path(STORAGE_PATH), exist_ok=True) 102 | record_file = hass.config.path(f"{STORAGE_PATH}/{config_entry.entry_id}_state.json") 103 | reset_file = hass.config.path(f"{STORAGE_PATH}/{DOMAIN}_reset.json") 104 | json_data = load_json(record_file, default={}) 105 | for history_type in HISTORIES.keys(): 106 | state = STATE_UNKNOWN 107 | if len(json_data) > 0: 108 | state = json_data[history_type]["history_state"] 109 | _LOGGER.debug(f"Load {history_type} history data {state}") 110 | h_sensor = HPGHistorySensor(history_type, DEVICE_CLASS_ENERGY, ident, state) 111 | sensors.append(h_sensor) 112 | state = STATE_UNKNOWN 113 | last_state = STATE_UNKNOWN 114 | last_time = 0 115 | if len(json_data) > 0: 116 | state = json_data[history_type]["real_state"] 117 | last_state = json_data["last_state"] 118 | last_time = json_data["last_time"] 119 | r_sensor = HPGRealSensor(history_type, DEVICE_CLASS_ENERGY, ident, h_sensor, state, last_state, last_time) 120 | sensors.append(r_sensor) 121 | updates[history_type] = r_sensor.update_state 122 | json_data = load_json(reset_file, default={}) 123 | if len(json_data) > 0: 124 | last_reset = json_data.get("last_reset") 125 | else: 126 | last_reset = 0 127 | for sensor_type in HPG_SENSORS.keys(): 128 | sensor = HPGSensor(coordinator, config_entry.entry_id, sensor_type, ident, updates, last_reset) 129 | sensors.append(sensor) 130 | if sensor.device_class == sensor_type: 131 | if ENERGY_SENSOR not in hass.data[DOMAIN]: 132 | hass.data[DOMAIN][ENERGY_SENSOR] = [] 133 | hass.data[DOMAIN][ENERGY_SENSOR].append(sensor) 134 | async_add_entities(sensors) 135 | 136 | 137 | class HPGBaseSensor(Entity): 138 | def __init__(self, sensor_type, ident): 139 | self._state = STATE_UNKNOWN 140 | self._sensor_type = sensor_type 141 | self._device_info = { 142 | "identifiers": {(DOMAIN, ident)}, 143 | "manufacturer": "Peacefair", 144 | "model": "PZEM-004T", 145 | "sw_version": VERSION, 146 | "name": "Peacefair Energy Monitor" 147 | } 148 | 149 | @property 150 | def state(self): 151 | return STATE_UNKNOWN if self._state == STATE_UNKNOWN else round(self._state, 2) 152 | 153 | @property 154 | def should_poll(self): 155 | return False 156 | 157 | @property 158 | def device_info(self): 159 | return self._device_info 160 | 161 | @property 162 | def device_class(self): 163 | return self._sensor_type 164 | 165 | @property 166 | def unique_id(self): 167 | return self._unique_id 168 | 169 | @property 170 | def unit_of_measurement(self): 171 | return HPG_SENSORS[self._sensor_type].get("unit") 172 | 173 | @property 174 | def icon(self): 175 | return HPG_SENSORS[self._sensor_type].get("icon") 176 | 177 | @property 178 | def state_class(self): 179 | return HPG_SENSORS[self._sensor_type].get("state_class") 180 | 181 | @property 182 | def capability_attributes(self): 183 | return {ATTR_STATE_CLASS: self.state_class} if self.state_class else {} 184 | 185 | 186 | class HPGHistorySensor(HPGBaseSensor): 187 | def __init__(self, history_type, sensor_type, ident, state): 188 | super().__init__(sensor_type, ident) 189 | self._unique_id = f"{DOMAIN}.{ident}_{history_type}_history" 190 | self.entity_id = self._unique_id 191 | self._history_type = history_type 192 | self._state = state 193 | 194 | @property 195 | def name(self): 196 | return HISTORIES[self._history_type].get("history_name") 197 | 198 | def update_state(self, state): 199 | self._state = state 200 | self.schedule_update_ha_state() 201 | 202 | 203 | class HPGRealSensor(HPGBaseSensor): 204 | def __init__(self, history_type, sensor_type, ident, history_sensor, state, last_state, last_time): 205 | super().__init__(sensor_type, ident) 206 | self._unique_id = f"{DOMAIN}.{ident}_{history_type}_real" 207 | self.entity_id = self._unique_id 208 | self._history_sensor = history_sensor 209 | self._history_type = history_type 210 | self._last_state = last_state 211 | self._last_time = last_time 212 | self._state = state 213 | 214 | @property 215 | def name(self): 216 | return HISTORIES[self._history_type].get("real_name") 217 | 218 | def update_state(self, cur_time, state): 219 | if state!= STATE_UNKNOWN and self._last_state != STATE_UNKNOWN: 220 | differ = state 221 | last_time = time.localtime(self._last_time) 222 | current_time = time.localtime(cur_time) 223 | if state >= self._last_state: 224 | differ = round(state - self._last_state, 3) 225 | if (self._history_type == HISTORY_DAY and last_time.tm_mday != current_time.tm_mday) \ 226 | or (self._history_type == HISTORY_WEEK and last_time.tm_wday != current_time.tm_wday and current_time.tm_wday == 0) \ 227 | or (self._history_type == HISTORY_MONTH and last_time.tm_mon != current_time.tm_mon)\ 228 | or (self._history_type == HISTORY_YEAR and last_time.tm_year != current_time.tm_year): 229 | self._history_sensor.update_state(self._state) 230 | self._state = differ 231 | elif self._state == STATE_UNKNOWN: 232 | self._state = differ 233 | else: 234 | self._state = self._state + differ 235 | self._last_time = cur_time 236 | self._last_state = state 237 | self.schedule_update_ha_state() 238 | return { 239 | "history_state": self._history_sensor._state, 240 | "real_state": self._state 241 | } 242 | 243 | 244 | class HPGSensor(CoordinatorEntity, HPGBaseSensor): 245 | def __init__(self, coordinator, entry_id, sensor_type, ident, energy_updates, last_reset): 246 | super().__init__(coordinator) 247 | HPGBaseSensor.__init__(self, sensor_type, ident) 248 | self._unique_id = f"{DOMAIN}.{ident}_{sensor_type}" 249 | self.entity_id = self._unique_id 250 | self._energy_updates = energy_updates 251 | self._last_reset = datetime.datetime.fromtimestamp(last_reset) 252 | self._record_file = f"{STORAGE_PATH}/{entry_id}_state.json" 253 | self._reset_file = f"{STORAGE_PATH}/{entry_id}_reset.json" 254 | if self._sensor_type == DEVICE_CLASS_ENERGY: 255 | coordinator.set_update(self.update_state) 256 | 257 | @property 258 | def state(self): 259 | if self._sensor_type in self.coordinator.data: 260 | return round(self.coordinator.data[self._sensor_type], 2) 261 | else: 262 | return STATE_UNKNOWN 263 | 264 | @property 265 | def name(self): 266 | return HPG_SENSORS[self._sensor_type].get("name") 267 | 268 | def update_state(self): 269 | cur_time = time.time() 270 | json_data = {"last_time": cur_time, "last_state": self.state} 271 | if self._energy_updates is not None: 272 | for real_type in self._energy_updates: 273 | json_data[real_type] = self._energy_updates[real_type](cur_time, self.state) 274 | save_json(self.hass.config.path(self._record_file), json_data) 275 | 276 | @property 277 | def last_reset(self): 278 | return self._last_reset if self._sensor_type == DEVICE_CLASS_ENERGY else None 279 | 280 | @final 281 | @property 282 | def state_attributes(self): 283 | return {ATTR_LAST_RESET: self.last_reset.isoformat()} if self._sensor_type == DEVICE_CLASS_ENERGY else {} 284 | 285 | def reset(self): 286 | self._last_reset = datetime.datetime.now() 287 | json_data = {"last_reset": self._last_reset.timestamp()} 288 | _LOGGER.debug(f"Energy reset") 289 | save_json(self.hass.config.path(self._reset_file), json_data) 290 | -------------------------------------------------------------------------------- /custom_components/peacefair_energy/services.yaml: -------------------------------------------------------------------------------- 1 | reset_energy: 2 | description: Reset the energy. 3 | fields: 4 | entity_id: 5 | description: Energy entity want to reset 6 | example: "sensor.192_168_1_100_energy" -------------------------------------------------------------------------------- /custom_components/peacefair_energy/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "device_exist": "Device is already configured, choice another one" 5 | }, 6 | "step": { 7 | "user": { 8 | "data": { 9 | "protocol": "Protocol", 10 | "host": "Host", 11 | "port": "Port", 12 | "slave": "Slave" 13 | }, 14 | "title": "Add Device" 15 | } 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /custom_components/peacefair_energy/translations/zh-Hans.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "device_exist": "该设备已经存在,请配置其它设备" 5 | }, 6 | "step": { 7 | "user": { 8 | "data": { 9 | "protocol": "协议", 10 | "host": "地址", 11 | "port": "端口", 12 | "slave": "从站编号" 13 | }, 14 | "title": "添加新设备" 15 | } 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Peacefair Energy Monitor", 3 | "domains": ["sensor"], 4 | "render_readme": true 5 | } 6 | --------------------------------------------------------------------------------