Home Assistant Git Exporter
This commit is contained in:
350
config/custom_components/apsystems_ecur/APSystemsSocket.py
Normal file
350
config/custom_components/apsystems_ecur/APSystemsSocket.py
Normal file
@@ -0,0 +1,350 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import socket
|
||||
import binascii
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
class APSystemsInvalidData(Exception):
|
||||
pass
|
||||
|
||||
class APSystemsSocket:
|
||||
def __init__(self, ipaddr, nographs, port=8899, raw_ecu=None, raw_inverter=None):
|
||||
global no_graphs
|
||||
no_graphs = nographs
|
||||
self.ipaddr = ipaddr
|
||||
self.port = port
|
||||
|
||||
# what do we expect socket data to end in
|
||||
self.recv_suffix = b'END\n'
|
||||
|
||||
# how long to wait on socket commands until we get our recv_suffix
|
||||
self.timeout = 10
|
||||
|
||||
# how big of a buffer to read at a time from the socket
|
||||
# https://github.com/ksheumaker/homeassistant-apsystems_ecur/issues/108
|
||||
self.recv_size = 1024
|
||||
|
||||
# how long to wait between socket open/closes
|
||||
self.socket_sleep_time = 5
|
||||
|
||||
self.cmd_suffix = "END\n"
|
||||
self.ecu_query = "APS1100160001" + self.cmd_suffix
|
||||
self.inverter_query_prefix = "APS1100280002"
|
||||
self.inverter_query_suffix = self.cmd_suffix
|
||||
self.inverter_signal_prefix = "APS1100280030"
|
||||
self.inverter_signal_suffix = self.cmd_suffix
|
||||
self.ecu_id = None
|
||||
self.qty_of_inverters = 0
|
||||
self.qty_of_online_inverters = 0
|
||||
self.lifetime_energy = 0
|
||||
self.current_power = 0
|
||||
self.today_energy = 0
|
||||
self.inverters = {}
|
||||
self.firmware = None
|
||||
self.timezone = None
|
||||
self.last_update = None
|
||||
self.vsl = 0
|
||||
self.tsl = 0
|
||||
self.ecu_raw_data = raw_ecu
|
||||
self.inverter_raw_data = raw_inverter
|
||||
self.inverter_raw_signal = None
|
||||
self.read_buffer = b''
|
||||
self.socket = None
|
||||
self.socket_open = False
|
||||
self.errors = []
|
||||
|
||||
def send_read_from_socket(self, cmd):
|
||||
try:
|
||||
self.sock.settimeout(self.timeout)
|
||||
self.sock.sendall(cmd.encode('utf-8'))
|
||||
time.sleep(self.socket_sleep_time)
|
||||
self.read_buffer = b''
|
||||
self.sock.settimeout(self.timeout)
|
||||
# An infinite loop was causing the integration to block
|
||||
# https://github.com/ksheumaker/homeassistant-apsystems_ecur/issues/115
|
||||
# Solution might cause a new issue when large solar array's applies
|
||||
self.read_buffer = self.sock.recv(self.recv_size)
|
||||
return self.read_buffer
|
||||
except Exception as err:
|
||||
self.close_socket()
|
||||
raise APSystemsInvalidData(err)
|
||||
|
||||
def close_socket(self):
|
||||
try:
|
||||
if self.socket_open:
|
||||
self.sock.shutdown(socket.SHUT_RDWR)
|
||||
self.sock.close()
|
||||
self.socket_open = False
|
||||
except Exception as err:
|
||||
raise APSystemsInvalidData(err)
|
||||
|
||||
def open_socket(self):
|
||||
self.socket_open = False
|
||||
try:
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
# self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
||||
self.sock.settimeout(self.timeout)
|
||||
self.sock.connect((self.ipaddr, self.port))
|
||||
self.socket_open = True
|
||||
except Exception as err:
|
||||
raise APSystemsInvalidData(err)
|
||||
|
||||
def query_ecu(self):
|
||||
#read ECU data
|
||||
self.open_socket()
|
||||
self.ecu_raw_data = self.send_read_from_socket(self.ecu_query)
|
||||
self.close_socket()
|
||||
try:
|
||||
self.process_ecu_data()
|
||||
except Exception as err:
|
||||
raise APSystemsInvalidData(err)
|
||||
|
||||
#read inverter data
|
||||
# Some ECUs like the socket to be closed and re-opened between commands
|
||||
self.open_socket()
|
||||
cmd = self.inverter_query_prefix + self.ecu_id + self.inverter_query_suffix
|
||||
self.inverter_raw_data = self.send_read_from_socket(cmd)
|
||||
self.close_socket()
|
||||
|
||||
#read signal data
|
||||
# Some ECUs like the socket to be closed and re-opened between commands
|
||||
self.open_socket()
|
||||
cmd = self.inverter_signal_prefix + self.ecu_id + self.inverter_signal_suffix
|
||||
self.inverter_raw_signal = self.send_read_from_socket(cmd)
|
||||
self.close_socket()
|
||||
|
||||
data = self.process_inverter_data()
|
||||
data["ecu_id"] = self.ecu_id
|
||||
if self.lifetime_energy != 0:
|
||||
data["lifetime_energy"] = self.lifetime_energy
|
||||
data["current_power"] = self.current_power
|
||||
# apply filter for ECU-R-pro firmware bug where both are zero
|
||||
if self.qty_of_inverters > 0:
|
||||
data["qty_of_inverters"] = self.qty_of_inverters
|
||||
data["today_energy"] = self.today_energy
|
||||
data["qty_of_online_inverters"] = self.qty_of_online_inverters
|
||||
return(data)
|
||||
|
||||
def aps_int_from_bytes(self, codec: bytes, start: int, length: int) -> int:
|
||||
try:
|
||||
return int (binascii.b2a_hex(codec[(start):(start+length)]), 16)
|
||||
except ValueError as err:
|
||||
debugdata = binascii.b2a_hex(codec)
|
||||
error = f"Unable to convert binary to int with length={length} at location={start} with data={debugdata}"
|
||||
raise APSystemsInvalidData(error)
|
||||
|
||||
def aps_uid(self, codec, start):
|
||||
return str(binascii.b2a_hex(codec[(start):(start+12)]))[2:14]
|
||||
|
||||
def aps_str(self, codec, start, amount):
|
||||
return str(codec[start:(start+amount)])[2:(amount+2)]
|
||||
|
||||
def aps_datetimestamp(self, codec, start, amount):
|
||||
timestr=str(binascii.b2a_hex(codec[start:(start+amount)]))[2:(amount+2)]
|
||||
return timestr[0:4]+"-"+timestr[4:6]+"-"+timestr[6:8]+" "+timestr[8:10]+":"+timestr[10:12]+":"+timestr[12:14]
|
||||
|
||||
def check_ecu_checksum(self, data, cmd):
|
||||
datalen = len(data) - 1
|
||||
try:
|
||||
checksum = int(data[5:9])
|
||||
except ValueError as err:
|
||||
debugdata = binascii.b2a_hex(data)
|
||||
error = f"could not extract checksum int from '{cmd}' data={debugdata}"
|
||||
raise APSystemsInvalidData(error)
|
||||
|
||||
if datalen != checksum:
|
||||
debugdata = binascii.b2a_hex(data)
|
||||
error = f"Checksum on '{cmd}' failed checksum={checksum} datalen={datalen} data={debugdata}"
|
||||
raise APSystemsInvalidData(error)
|
||||
|
||||
start_str = self.aps_str(data, 0, 3)
|
||||
end_str = self.aps_str(data, len(data) - 4, 3)
|
||||
|
||||
if start_str != 'APS':
|
||||
debugdata = binascii.b2a_hex(data)
|
||||
error = f"Result on '{cmd}' incorrect start signature '{start_str}' != APS data={debugdata}"
|
||||
raise APSystemsInvalidData(error)
|
||||
|
||||
if end_str != 'END':
|
||||
debugdata = binascii.b2a_hex(data)
|
||||
error = f"Result on '{cmd}' incorrect end signature '{end_str}' != END data={debugdata}"
|
||||
raise APSystemsInvalidData(error)
|
||||
|
||||
return True
|
||||
|
||||
def process_ecu_data(self, data=None):
|
||||
if self.ecu_raw_data != '' and (self.aps_str(self.ecu_raw_data,9,4)) == '0001':
|
||||
data = self.ecu_raw_data
|
||||
_LOGGER.debug(binascii.b2a_hex(data))
|
||||
self.check_ecu_checksum(data, "ECU Query")
|
||||
self.ecu_id = self.aps_str(data, 13, 12)
|
||||
self.lifetime_energy = self.aps_int_from_bytes(data, 27, 4) / 10
|
||||
self.current_power = self.aps_int_from_bytes(data, 31, 4)
|
||||
self.today_energy = self.aps_int_from_bytes(data, 35, 4) / 100
|
||||
if self.aps_str(data,25,2) == "01":
|
||||
self.qty_of_inverters = self.aps_int_from_bytes(data, 46, 2)
|
||||
self.qty_of_online_inverters = self.aps_int_from_bytes(data, 48, 2)
|
||||
self.vsl = int(self.aps_str(data, 52, 3))
|
||||
self.firmware = self.aps_str(data, 55, self.vsl)
|
||||
self.tsl = int(self.aps_str(data, 55 + self.vsl, 3))
|
||||
self.timezone = self.aps_str(data, 58 + self.vsl, self.tsl)
|
||||
elif self.aps_str(data,25,2) == "02":
|
||||
self.qty_of_inverters = self.aps_int_from_bytes(data, 39, 2)
|
||||
self.qty_of_online_inverters = self.aps_int_from_bytes(data, 41, 2)
|
||||
self.vsl = int(self.aps_str(data, 49, 3))
|
||||
self.firmware = self.aps_str(data, 52, self.vsl)
|
||||
|
||||
def process_signal_data(self, data=None):
|
||||
signal_data = {}
|
||||
if self.inverter_raw_signal != '' and (self.aps_str(self.inverter_raw_signal,9,4)) == '0030':
|
||||
data = self.inverter_raw_signal
|
||||
_LOGGER.debug(binascii.b2a_hex(data))
|
||||
self.check_ecu_checksum(data, "Signal Query")
|
||||
if not self.qty_of_inverters:
|
||||
return signal_data
|
||||
location = 15
|
||||
for i in range(0, self.qty_of_inverters):
|
||||
uid = self.aps_uid(data, location)
|
||||
location += 6
|
||||
strength = data[location]
|
||||
location += 1
|
||||
strength = int((strength / 255) * 100)
|
||||
signal_data[uid] = strength
|
||||
return signal_data
|
||||
|
||||
def process_inverter_data(self, data=None):
|
||||
output = {}
|
||||
if self.inverter_raw_data != '' and (self.aps_str(self.inverter_raw_data,9,4)) == '0002':
|
||||
data = self.inverter_raw_data
|
||||
_LOGGER.debug(binascii.b2a_hex(data))
|
||||
self.check_ecu_checksum(data, "Inverter data")
|
||||
istr = ''
|
||||
cnt1 = 0
|
||||
cnt2 = 26
|
||||
if self.aps_str(data, 14, 2) == '00':
|
||||
timestamp = self.aps_datetimestamp(data, 19, 14)
|
||||
inverter_qty = self.aps_int_from_bytes(data, 17, 2)
|
||||
self.last_update = timestamp
|
||||
output["timestamp"] = timestamp
|
||||
output["inverters"] = {}
|
||||
signal = self.process_signal_data()
|
||||
inverters = {}
|
||||
|
||||
while cnt1 < inverter_qty:
|
||||
inv={}
|
||||
if self.aps_str(data, 15, 2) == '01':
|
||||
inverter_uid = self.aps_uid(data, cnt2)
|
||||
inv["uid"] = inverter_uid
|
||||
inv["online"] = bool(self.aps_int_from_bytes(data, cnt2 + 6, 1))
|
||||
istr = self.aps_str(data, cnt2 + 7, 2)
|
||||
|
||||
# Should graphs be updated?
|
||||
if inv["online"] == False and no_graphs == True:
|
||||
inv["signal"] = None
|
||||
else:
|
||||
inv["signal"] = signal.get(inverter_uid, 0)
|
||||
|
||||
# Distinguishes the different inverters from this point down
|
||||
if istr in [ '01', '04', '05']:
|
||||
power = []
|
||||
voltages = []
|
||||
|
||||
# Should graphs be updated?
|
||||
if inv["online"] == True:
|
||||
inv["temperature"] = self.aps_int_from_bytes(data, cnt2 + 11, 2) - 100
|
||||
if inv["online"] == False and no_graphs == True:
|
||||
inv["frequency"] = None
|
||||
power.append(None)
|
||||
voltages.append(None)
|
||||
power.append(None)
|
||||
voltages.append(None)
|
||||
else:
|
||||
inv["frequency"] = self.aps_int_from_bytes(data, cnt2 + 9, 2) / 10
|
||||
power.append(self.aps_int_from_bytes(data, cnt2 + 13, 2))
|
||||
voltages.append(self.aps_int_from_bytes(data, cnt2 + 15, 2))
|
||||
power.append(self.aps_int_from_bytes(data, cnt2 + 17, 2))
|
||||
voltages.append(self.aps_int_from_bytes(data, cnt2 + 19, 2))
|
||||
|
||||
inv_details = {
|
||||
"model" : "YC600/DS3 series",
|
||||
"channel_qty" : 2,
|
||||
"power" : power,
|
||||
"voltage" : voltages
|
||||
}
|
||||
inv.update(inv_details)
|
||||
cnt2 = cnt2 + 21
|
||||
elif istr == '02':
|
||||
power = []
|
||||
voltages = []
|
||||
|
||||
# Should graphs be updated?
|
||||
if inv["online"]:
|
||||
inv["temperature"] = self.aps_int_from_bytes(data, cnt2 + 11, 2) - 100
|
||||
if inv["online"] == False and no_graphs == True:
|
||||
inv["frequency"] = None
|
||||
power.append(None)
|
||||
voltages.append(None)
|
||||
power.append(None)
|
||||
voltages.append(None)
|
||||
power.append(None)
|
||||
voltages.append(None)
|
||||
power.append(None)
|
||||
else:
|
||||
inv["frequency"] = self.aps_int_from_bytes(data, cnt2 + 9, 2) / 10
|
||||
power.append(self.aps_int_from_bytes(data, cnt2 + 13, 2))
|
||||
voltages.append(self.aps_int_from_bytes(data, cnt2 + 15, 2))
|
||||
power.append(self.aps_int_from_bytes(data, cnt2 + 17, 2))
|
||||
voltages.append(self.aps_int_from_bytes(data, cnt2 + 19, 2))
|
||||
power.append(self.aps_int_from_bytes(data, cnt2 + 21, 2))
|
||||
voltages.append(self.aps_int_from_bytes(data, cnt2 + 23, 2))
|
||||
power.append(self.aps_int_from_bytes(data, cnt2 + 25, 2))
|
||||
|
||||
inv_details = {
|
||||
"model" : "YC1000/QT2",
|
||||
"channel_qty" : 4,
|
||||
"power" : power,
|
||||
"voltage" : voltages
|
||||
}
|
||||
inv.update(inv_details)
|
||||
cnt2 = cnt2 + 27
|
||||
elif istr == '03':
|
||||
power = []
|
||||
voltages = []
|
||||
|
||||
# Should graphs be updated?
|
||||
if inv["online"]:
|
||||
inv["temperature"] = self.aps_int_from_bytes(data, cnt2 + 11, 2) - 100
|
||||
if inv["online"] == False and no_graphs == True:
|
||||
inv["frequency"] = None
|
||||
power.append(None)
|
||||
voltages.append(None)
|
||||
power.append(None)
|
||||
power.append(None)
|
||||
power.append(None)
|
||||
else:
|
||||
inv["frequency"] = self.aps_int_from_bytes(data, cnt2 + 9, 2) / 10
|
||||
power.append(self.aps_int_from_bytes(data, cnt2 + 13, 2))
|
||||
voltages.append(self.aps_int_from_bytes(data, cnt2 + 15, 2))
|
||||
power.append(self.aps_int_from_bytes(data, cnt2 + 17, 2))
|
||||
power.append(self.aps_int_from_bytes(data, cnt2 + 19, 2))
|
||||
power.append(self.aps_int_from_bytes(data, cnt2 + 21, 2))
|
||||
|
||||
inv_details = {
|
||||
"model" : "QS1",
|
||||
"channel_qty" : 4,
|
||||
"power" : power,
|
||||
"voltage" : voltages
|
||||
}
|
||||
inv.update(inv_details)
|
||||
cnt2 = cnt2 + 23
|
||||
else:
|
||||
cnt2 = cnt2 + 9
|
||||
inverters[inverter_uid] = inv
|
||||
cnt1 = cnt1 + 1
|
||||
self.inverters = inverters
|
||||
output["inverters"] = inverters
|
||||
return (output)
|
||||
241
config/custom_components/apsystems_ecur/__init__.py
Normal file
241
config/custom_components/apsystems_ecur/__init__.py
Normal file
@@ -0,0 +1,241 @@
|
||||
import logging
|
||||
import requests
|
||||
|
||||
import voluptuous as vol
|
||||
import traceback
|
||||
import datetime as dt
|
||||
from datetime import timedelta
|
||||
|
||||
from .APSystemsSocket import APSystemsSocket, APSystemsInvalidData
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.discovery import load_platform
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant import config_entries, exceptions
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.components.persistent_notification import (
|
||||
create as create_persistent_notification
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PLATFORMS = [ "sensor", "binary_sensor", "switch" ]
|
||||
|
||||
class WiFiSet():
|
||||
ipaddr = ""
|
||||
ssid = ""
|
||||
wpa = ""
|
||||
cache = 3
|
||||
WiFiSet = WiFiSet()
|
||||
|
||||
# handle all the communications with the ECUR class and deal with our need for caching, etc
|
||||
class ECUR():
|
||||
def __init__(self, ipaddr, ssid, wpa, cache, nographs):
|
||||
self.ecu = APSystemsSocket(ipaddr, nographs)
|
||||
self.cache_count = 0
|
||||
self.data_from_cache = False
|
||||
self.querying = True
|
||||
self.inverters_online = True
|
||||
self.ecu_restarting = False
|
||||
self.cached_data = {}
|
||||
WiFiSet.ipaddr = ipaddr
|
||||
WiFiSet.ssid = ssid
|
||||
WiFiSet.wpa = wpa
|
||||
WiFiSet.cache = cache
|
||||
|
||||
def stop_query(self):
|
||||
self.querying = False
|
||||
|
||||
def start_query(self):
|
||||
self.querying = True
|
||||
|
||||
def inverters_off(self):
|
||||
headers = {'X-Requested-With': 'XMLHttpRequest'}
|
||||
url = 'http://'+ str(WiFiSet.ipaddr) + '/index.php/configuration/set_switch_all_off'
|
||||
try:
|
||||
get_url = requests.post(url, headers=headers)
|
||||
self.inverters_online = False
|
||||
_LOGGER.debug(f"Response from ECU on switching the inverters off: {str(get_url.status_code)}")
|
||||
except Exception as err:
|
||||
_LOGGER.warning(f"Attempt to switch inverters off failed with error: {err} (This switch is only compatible with ECU-R pro and ECU-C type ECU's)")
|
||||
|
||||
def inverters_on(self):
|
||||
headers = {'X-Requested-With': 'XMLHttpRequest'}
|
||||
url = 'http://'+ str(WiFiSet.ipaddr) + '/index.php/configuration/set_switch_all_on'
|
||||
try:
|
||||
get_url = requests.post(url, headers=headers)
|
||||
self.inverters_online = True
|
||||
_LOGGER.debug(f"Response from ECU on switching the inverters on: {str(get_url.status_code)}")
|
||||
except Exception as err:
|
||||
_LOGGER.warning(f"Attempt to switch inverters on failed with error: {err} (This switch is only compatible with ECU-R pro and ECU-C type ECU's)")
|
||||
|
||||
def use_cached_data(self, msg):
|
||||
# we got invalid data, so we need to pull from cache
|
||||
self.error_msg = msg
|
||||
self.cache_count += 1
|
||||
self.data_from_cache = True
|
||||
|
||||
if self.cache_count == WiFiSet.cache:
|
||||
_LOGGER.warning(f"Communication with the ECU failed after {WiFiSet.cache} repeated attempts.")
|
||||
data = {'SSID': WiFiSet.ssid, 'channel': 0, 'method': 2, 'psk_wep': '', 'psk_wpa': WiFiSet.wpa}
|
||||
_LOGGER.debug(f"Data sent with URL: {data}")
|
||||
# Determine ECU type to decide ECU restart (for ECU-C and ECU-R with sunspec only)
|
||||
if (self.cached_data.get("ecu_id", None)[0:3] == "215") or (self.cached_data.get("ecu_id", None)[0:4] == "2162"):
|
||||
url = 'http://' + str(WiFiSet.ipaddr) + '/index.php/management/set_wlan_ap'
|
||||
headers = {'X-Requested-With': 'XMLHttpRequest'}
|
||||
try:
|
||||
get_url = requests.post(url, headers=headers, data=data)
|
||||
_LOGGER.debug(f"Response from ECU on restart: {str(get_url.status_code)}")
|
||||
self.ecu_restarting = True
|
||||
except Exception as err:
|
||||
_LOGGER.warning(f"Attempt to restart ECU failed with error: {err}. Querying is stopped automatically.")
|
||||
self.querying = False
|
||||
else:
|
||||
# Older ECU-R models starting with 2160
|
||||
_LOGGER.warning("Try manually power cycling the ECU. Querying is stopped automatically, turn switch back on after restart of ECU.")
|
||||
self.querying = False
|
||||
|
||||
if self.cached_data.get("ecu_id", None) == None:
|
||||
_LOGGER.debug(f"Cached data {self.cached_data}")
|
||||
raise UpdateFailed(f"Unable to get correct data from ECU, and no cached data. See log for details, and try power cycling the ECU.")
|
||||
return self.cached_data
|
||||
|
||||
def update(self):
|
||||
data = {}
|
||||
# if we aren't actively quering data, pull data form the cache
|
||||
# this is so we can stop querying after sunset
|
||||
if not self.querying:
|
||||
_LOGGER.debug("Not querying ECU due to query=False")
|
||||
data = self.cached_data
|
||||
self.data_from_cache = True
|
||||
data["data_from_cache"] = self.data_from_cache
|
||||
data["querying"] = self.querying
|
||||
return self.cached_data
|
||||
|
||||
_LOGGER.debug("Querying ECU...")
|
||||
try:
|
||||
data = self.ecu.query_ecu()
|
||||
_LOGGER.debug("Got data from ECU")
|
||||
|
||||
# we got good results, so we store it and set flags about our cache state
|
||||
if data["ecu_id"] != None:
|
||||
self.cached_data = data
|
||||
self.cache_count = 0
|
||||
self.data_from_cache = False
|
||||
self.ecu_restarting = False
|
||||
self.error_message = ""
|
||||
else:
|
||||
msg = f"Using cached data from last successful communication from ECU. Error: no ecu_id returned"
|
||||
_LOGGER.warning(msg)
|
||||
data = self.use_cached_data(msg)
|
||||
|
||||
except APSystemsInvalidData as err:
|
||||
msg = f"Using cached data from last successful communication from ECU. Invalid data error: {err}"
|
||||
if str(err) != 'timed out':
|
||||
_LOGGER.warning(msg)
|
||||
data = self.use_cached_data(msg)
|
||||
|
||||
except Exception as err:
|
||||
msg = f"Using cached data from last successful communication from ECU. Exception error: {err}"
|
||||
_LOGGER.warning(msg)
|
||||
data = self.use_cached_data(msg)
|
||||
|
||||
data["data_from_cache"] = self.data_from_cache
|
||||
data["querying"] = self.querying
|
||||
data["restart_ecu"] = self.ecu_restarting
|
||||
_LOGGER.debug(f"Returning {data}")
|
||||
if data.get("ecu_id", None) == None:
|
||||
raise UpdateFailed(f"Somehow data doesn't contain a valid ecu_id")
|
||||
return data
|
||||
|
||||
async def update_listener(hass, config):
|
||||
# Handle options update being triggered by config entry options updates
|
||||
_LOGGER.debug(f"Configuration updated: {config.as_dict()}")
|
||||
ecu = ECUR(config.data["host"],
|
||||
config.data["SSID"],
|
||||
config.data["WPA-PSK"],
|
||||
config.data["CACHE"],
|
||||
config.data["stop_graphs"]
|
||||
)
|
||||
|
||||
async def async_setup_entry(hass, config):
|
||||
# Setup the APsystems platform """
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
host = config.data["host"]
|
||||
interval = timedelta(seconds=config.data["scan_interval"])
|
||||
# Defaults for new parameters that might not have been set yet from previous integration versions
|
||||
cache = config.data.get("CACHE", 5)
|
||||
ssid = config.data.get("SSID", "ECU-WiFi_SSID")
|
||||
wpa = config.data.get("WPA-PSK", "myWiFipassword")
|
||||
nographs = config.data.get("stop_graphs", False)
|
||||
ecu = ECUR(host, ssid, wpa, cache, nographs)
|
||||
|
||||
|
||||
async def do_ecu_update():
|
||||
return await hass.async_add_executor_job(ecu.update)
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_method=do_ecu_update,
|
||||
update_interval=interval,
|
||||
)
|
||||
|
||||
hass.data[DOMAIN] = {
|
||||
"ecu" : ecu,
|
||||
"coordinator" : coordinator
|
||||
}
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=config.entry_id,
|
||||
identifiers={(DOMAIN, f"ecu_{ecu.ecu.ecu_id}")},
|
||||
manufacturer="APSystems",
|
||||
suggested_area="Roof",
|
||||
name=f"ECU {ecu.ecu.ecu_id}",
|
||||
model=ecu.ecu.firmware,
|
||||
sw_version=ecu.ecu.firmware,
|
||||
)
|
||||
|
||||
inverters = coordinator.data.get("inverters", {})
|
||||
for uid,inv_data in inverters.items():
|
||||
model = inv_data.get("model", "Inverter")
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=config.entry_id,
|
||||
identifiers={(DOMAIN, f"inverter_{uid}")},
|
||||
manufacturer="APSystems",
|
||||
suggested_area="Roof",
|
||||
name=f"Inverter {uid}",
|
||||
model=inv_data.get("model")
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(config, PLATFORMS)
|
||||
config.async_on_unload(config.add_update_listener(update_listener))
|
||||
return True
|
||||
|
||||
async def async_remove_config_entry_device(hass, config, device_entry) -> bool:
|
||||
if device_entry is not None:
|
||||
# Notify the user that the device has been removed
|
||||
create_persistent_notification(
|
||||
hass,
|
||||
title="Important notification",
|
||||
message=f"The following device was removed from the system: {device_entry}"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
async def async_unload_entry(hass, config):
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(config, PLATFORMS)
|
||||
coordinator = hass.data[DOMAIN].get("coordinator")
|
||||
ecu = hass.data[DOMAIN].get("ecu")
|
||||
ecu.stop_query()
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(config.entry_id)
|
||||
return unload_ok
|
||||
92
config/custom_components/apsystems_ecur/binary_sensor.py
Normal file
92
config/custom_components/apsystems_ecur/binary_sensor.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
RELOAD_ICON,
|
||||
CACHE_ICON,
|
||||
RESTART_ICON
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(hass, config, add_entities, discovery_info=None):
|
||||
|
||||
ecu = hass.data[DOMAIN].get("ecu")
|
||||
coordinator = hass.data[DOMAIN].get("coordinator")
|
||||
|
||||
sensors = [
|
||||
APSystemsECUBinarySensor(coordinator, ecu, "data_from_cache",
|
||||
label="Using Cached Data", icon=CACHE_ICON),
|
||||
APSystemsECUBinarySensor(coordinator, ecu, "restart_ecu",
|
||||
label="Restart", icon=RESTART_ICON)
|
||||
]
|
||||
add_entities(sensors)
|
||||
|
||||
|
||||
class APSystemsECUBinarySensor(CoordinatorEntity, BinarySensorEntity):
|
||||
|
||||
def __init__(self, coordinator, ecu, field, label=None, devclass=None, icon=None):
|
||||
|
||||
super().__init__(coordinator)
|
||||
|
||||
self.coordinator = coordinator
|
||||
|
||||
self._ecu = ecu
|
||||
self._field = field
|
||||
self._label = label
|
||||
if not label:
|
||||
self._label = field
|
||||
self._icon = icon
|
||||
|
||||
self._name = f"ECU {self._label}"
|
||||
self._state = None
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
return f"{self._ecu.ecu.ecu_id}_{self._field}"
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
return self.coordinator.data.get(self._field)
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
|
||||
attrs = {
|
||||
"ecu_id" : self._ecu.ecu.ecu_id,
|
||||
"firmware" : self._ecu.ecu.firmware,
|
||||
"timezone" : self._ecu.ecu.timezone,
|
||||
"last_update" : self._ecu.ecu.last_update
|
||||
}
|
||||
return attrs
|
||||
|
||||
@property
|
||||
def entity_category(self):
|
||||
return EntityCategory.DIAGNOSTIC
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
parent = f"ecu_{self._ecu.ecu.ecu_id}"
|
||||
return {
|
||||
"identifiers": {
|
||||
(DOMAIN, parent),
|
||||
}
|
||||
}
|
||||
|
||||
103
config/custom_components/apsystems_ecur/config_flow.py
Normal file
103
config/custom_components/apsystems_ecur/config_flow.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import logging
|
||||
import voluptuous as vol
|
||||
import traceback
|
||||
from datetime import timedelta
|
||||
from homeassistant.core import callback
|
||||
from .APSystemsSocket import APSystemsSocket, APSystemsInvalidData
|
||||
from homeassistant import config_entries, exceptions
|
||||
from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
from .const import DOMAIN, CONF_SSID, CONF_WPA_PSK, CONF_CACHE, CONF_STOP_GRAPHS
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_SCAN_INTERVAL, default=300): int,
|
||||
vol.Optional(CONF_CACHE, default=5): int,
|
||||
vol.Optional(CONF_SSID, default="ECU-WIFI_local"): str,
|
||||
vol.Optional(CONF_WPA_PSK, default="default"): str,
|
||||
vol.Optional(CONF_STOP_GRAPHS, default=False): bool,
|
||||
})
|
||||
|
||||
@config_entries.HANDLERS.register(DOMAIN)
|
||||
class APSsystemsFlowHandler(config_entries.ConfigFlow):
|
||||
|
||||
VERSION = 1
|
||||
def __init__(self):
|
||||
_LOGGER.debug("Starting config flow class...")
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
_LOGGER.debug("Starting user step")
|
||||
errors = {}
|
||||
if user_input is None:
|
||||
_LOGGER.debug("Show form because user input is empty")
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
_LOGGER.debug("User input is not empty, processing input")
|
||||
try:
|
||||
_LOGGER.debug("Initial attempt to query ECU")
|
||||
ap_ecu = APSystemsSocket(user_input["host"], user_input["stop_graphs"])
|
||||
test_query = await self.hass.async_add_executor_job(ap_ecu.query_ecu)
|
||||
ecu_id = test_query.get("ecu_id", None)
|
||||
if ecu_id != None:
|
||||
return self.async_create_entry(title=f"ECU: {ecu_id}", data=user_input)
|
||||
else:
|
||||
errors["host"] = "no_ecuid"
|
||||
except APSystemsInvalidData as err:
|
||||
_LOGGER.exception(f"APSystemsInvalidData exception: {err}")
|
||||
errors["host"] = "cannot_connect"
|
||||
except Exception as err:
|
||||
_LOGGER.exception(f"Unknown error occurred during setup: {err}")
|
||||
errors["host"] = "unknown"
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
_LOGGER.debug("get options flow")
|
||||
return APSsystemsOptionsFlowHandler(config_entry)
|
||||
|
||||
class APSsystemsOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
||||
_LOGGER.debug("Starting options flow step class")
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
errors = {}
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
errors=errors,
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_HOST, default=self.config_entry.data.get(CONF_HOST)): str,
|
||||
vol.Optional(CONF_SCAN_INTERVAL, default=300,
|
||||
description={"suggested_value": self.config_entry.data.get(CONF_SCAN_INTERVAL)}): int,
|
||||
vol.Optional(CONF_CACHE, default=5,
|
||||
description={"suggested_value": self.config_entry.data.get(CONF_CACHE)}): int,
|
||||
vol.Optional(CONF_SSID, default="ECU-WiFi_SSID",
|
||||
description={"suggested_value": self.config_entry.data.get(CONF_SSID)}): str,
|
||||
vol.Optional(CONF_WPA_PSK, default="myWiFipassword",
|
||||
description={"suggested_value": self.config_entry.data.get(CONF_WPA_PSK)}): str,
|
||||
vol.Optional(CONF_STOP_GRAPHS, default=self.config_entry.data.get(CONF_STOP_GRAPHS)): bool
|
||||
})
|
||||
)
|
||||
try:
|
||||
ap_ecu = APSystemsSocket(user_input["host"], user_input["stop_graphs"])
|
||||
_LOGGER.debug("Attempt to query ECU")
|
||||
test_query = await self.hass.async_add_executor_job(ap_ecu.query_ecu)
|
||||
ecu_id = test_query.get("ecu_id", None)
|
||||
if ecu_id != None:
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry, data=user_input, options=self.config_entry.options
|
||||
)
|
||||
coordinator = self.hass.data[DOMAIN].get("coordinator")
|
||||
coordinator.update_interval = timedelta(seconds=self.config_entry.data.get(CONF_SCAN_INTERVAL))
|
||||
return self.async_create_entry(title=f"ECU: {ecu_id}", data={})
|
||||
else:
|
||||
errors["host"] = "no_ecuid"
|
||||
except APSystemsInvalidData as err:
|
||||
errors["host"] = "cannot_connect"
|
||||
except Exception as err:
|
||||
_LOGGER.debug(f"Unknown error occurred during setup: {err}")
|
||||
errors["host"] = "unknown"
|
||||
13
config/custom_components/apsystems_ecur/const.py
Normal file
13
config/custom_components/apsystems_ecur/const.py
Normal file
@@ -0,0 +1,13 @@
|
||||
DOMAIN = 'apsystems_ecur'
|
||||
SOLAR_ICON = "mdi:solar-power"
|
||||
FREQ_ICON = "mdi:sine-wave"
|
||||
SIGNAL_ICON = "mdi:signal"
|
||||
RELOAD_ICON = "mdi:reload"
|
||||
CACHE_ICON = "mdi:cached"
|
||||
RESTART_ICON = "mdi:restart"
|
||||
POWER_ICON = "mdi:power"
|
||||
|
||||
CONF_SSID = "SSID"
|
||||
CONF_WPA_PSK = "WPA-PSK"
|
||||
CONF_CACHE = "CACHE"
|
||||
CONF_STOP_GRAPHS = "stop_graphs"
|
||||
30
config/custom_components/apsystems_ecur/diagnostics.py
Normal file
30
config/custom_components/apsystems_ecur/diagnostics.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
TO_REDACT = {CONF_TOKEN}
|
||||
|
||||
from .const import (
|
||||
DOMAIN
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_get_device_diagnostics(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry
|
||||
) -> dict:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
_LOGGER.debug("Diagnostics being called")
|
||||
|
||||
ecu = hass.data[DOMAIN].get("ecu")
|
||||
_LOGGER.debug(f"Diagnostics being called {ecu}")
|
||||
|
||||
diag_data = {"entry": async_redact_data(ecu.ecu.dump_data(), TO_REDACT)}
|
||||
|
||||
return diag_data
|
||||
14
config/custom_components/apsystems_ecur/manifest.json
Normal file
14
config/custom_components/apsystems_ecur/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"domain": "apsystems_ecur",
|
||||
"name": "APSystems PV solar ECU",
|
||||
"codeowners": ["@ksheumaker"],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/ksheumaker/homeassistant-apsystems_ecur",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/ksheumaker/homeassistant-apsystems_ecur/issues",
|
||||
"loggers": ["custom_components.apsystems_ecur"],
|
||||
"requirements": [],
|
||||
"version": "v1.4.3"
|
||||
}
|
||||
284
config/custom_components/apsystems_ecur/sensor.py
Normal file
284
config/custom_components/apsystems_ecur/sensor.py
Normal file
@@ -0,0 +1,284 @@
|
||||
from datetime import timedelta, datetime, date
|
||||
import logging
|
||||
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
)
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorStateClass,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
SOLAR_ICON,
|
||||
FREQ_ICON,
|
||||
SIGNAL_ICON
|
||||
)
|
||||
|
||||
from homeassistant.const import (
|
||||
UnitOfPower,
|
||||
UnitOfEnergy,
|
||||
UnitOfTemperature,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfFrequency,
|
||||
PERCENTAGE
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(hass, config, add_entities, discovery_info=None):
|
||||
|
||||
ecu = hass.data[DOMAIN].get("ecu")
|
||||
coordinator = hass.data[DOMAIN].get("coordinator")
|
||||
|
||||
sensors = [
|
||||
APSystemsECUSensor(coordinator, ecu, "current_power",
|
||||
label="Current Power",
|
||||
unit=UnitOfPower.WATT,
|
||||
devclass=SensorDeviceClass.POWER,
|
||||
icon=SOLAR_ICON,
|
||||
stateclass=SensorStateClass.MEASUREMENT
|
||||
),
|
||||
APSystemsECUSensor(coordinator, ecu, "today_energy",
|
||||
label="Today Energy",
|
||||
unit=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
devclass=SensorDeviceClass.ENERGY,
|
||||
icon=SOLAR_ICON,
|
||||
stateclass=SensorStateClass.TOTAL_INCREASING
|
||||
),
|
||||
APSystemsECUSensor(coordinator, ecu, "lifetime_energy",
|
||||
label="Lifetime Energy",
|
||||
unit=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
devclass=SensorDeviceClass.ENERGY,
|
||||
icon=SOLAR_ICON,
|
||||
stateclass=SensorStateClass.TOTAL_INCREASING
|
||||
),
|
||||
APSystemsECUSensor(coordinator, ecu, "qty_of_inverters",
|
||||
label="Inverters",
|
||||
icon=SOLAR_ICON,
|
||||
entity_category=EntityCategory.DIAGNOSTIC
|
||||
),
|
||||
APSystemsECUSensor(coordinator, ecu, "qty_of_online_inverters",
|
||||
label="Inverters Online",
|
||||
icon=SOLAR_ICON,
|
||||
entity_category=EntityCategory.DIAGNOSTIC
|
||||
),
|
||||
]
|
||||
|
||||
inverters = coordinator.data.get("inverters", {})
|
||||
for uid,inv_data in inverters.items():
|
||||
_LOGGER.debug(f"Inverter {uid} {inv_data.get('channel_qty')}")
|
||||
# https://github.com/ksheumaker/homeassistant-apsystems_ecur/issues/110
|
||||
if inv_data.get("channel_qty") != None:
|
||||
sensors.extend([
|
||||
APSystemsECUInverterSensor(coordinator, ecu, uid, "temperature",
|
||||
label="Temperature",
|
||||
unit=UnitOfTemperature.CELSIUS,
|
||||
devclass=SensorDeviceClass.TEMPERATURE,
|
||||
stateclass=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC
|
||||
),
|
||||
APSystemsECUInverterSensor(coordinator, ecu, uid, "frequency",
|
||||
label="Frequency",
|
||||
unit=UnitOfFrequency.HERTZ,
|
||||
stateclass=SensorStateClass.MEASUREMENT,
|
||||
devclass=SensorDeviceClass.FREQUENCY,
|
||||
icon=FREQ_ICON,
|
||||
entity_category=EntityCategory.DIAGNOSTIC
|
||||
),
|
||||
APSystemsECUInverterSensor(coordinator, ecu, uid, "voltage",
|
||||
label="Voltage",
|
||||
unit=UnitOfElectricPotential.VOLT,
|
||||
stateclass=SensorStateClass.MEASUREMENT,
|
||||
devclass=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC
|
||||
),
|
||||
APSystemsECUInverterSensor(coordinator, ecu, uid, "signal",
|
||||
label="Signal",
|
||||
unit=PERCENTAGE,
|
||||
stateclass=SensorStateClass.MEASUREMENT,
|
||||
devclass=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
icon=SIGNAL_ICON,
|
||||
entity_category=EntityCategory.DIAGNOSTIC
|
||||
)
|
||||
])
|
||||
for i in range(0, inv_data.get("channel_qty", 0)):
|
||||
sensors.append(
|
||||
APSystemsECUInverterSensor(coordinator, ecu, uid, f"power",
|
||||
index=i, label=f"Power Ch {i+1}",
|
||||
unit=UnitOfPower.WATT,
|
||||
devclass=SensorDeviceClass.POWER,
|
||||
icon=SOLAR_ICON,
|
||||
stateclass=SensorStateClass.MEASUREMENT
|
||||
)
|
||||
)
|
||||
add_entities(sensors)
|
||||
|
||||
|
||||
class APSystemsECUInverterSensor(CoordinatorEntity, SensorEntity):
|
||||
def __init__(self, coordinator, ecu, uid, field, index=0, label=None, icon=None, unit=None, devclass=None, stateclass=None, entity_category=None):
|
||||
|
||||
super().__init__(coordinator)
|
||||
|
||||
self.coordinator = coordinator
|
||||
|
||||
self._index = index
|
||||
self._uid = uid
|
||||
self._ecu = ecu
|
||||
self._field = field
|
||||
self._devclass = devclass
|
||||
self._label = label
|
||||
if not label:
|
||||
self._label = field
|
||||
self._icon = icon
|
||||
self._unit = unit
|
||||
self._stateclass = stateclass
|
||||
self._entity_category = entity_category
|
||||
|
||||
self._name = f"Inverter {self._uid} {self._label}"
|
||||
self._state = None
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
field = self._field
|
||||
if self._index != None:
|
||||
field = f"{field}_{self._index}"
|
||||
return f"{self._ecu.ecu.ecu_id}_{self._uid}_{field}"
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
return self._devclass
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
_LOGGER.debug(f"State called for {self._field}")
|
||||
if self._field == "voltage":
|
||||
return self.coordinator.data.get("inverters", {}).get(self._uid, {}).get("voltage", [])[0]
|
||||
elif self._field == "power":
|
||||
_LOGGER.debug(f"POWER {self._uid} {self._index}")
|
||||
return self.coordinator.data.get("inverters", {}).get(self._uid, {}).get("power", [])[self._index]
|
||||
else:
|
||||
return self.coordinator.data.get("inverters", {}).get(self._uid, {}).get(self._field)
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
return self._unit
|
||||
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
attrs = {
|
||||
"ecu_id" : self._ecu.ecu.ecu_id,
|
||||
"inverter_uid" : self._uid,
|
||||
"last_update" : self._ecu.ecu.last_update,
|
||||
}
|
||||
return attrs
|
||||
|
||||
@property
|
||||
def state_class(self):
|
||||
_LOGGER.debug(f"State class {self._stateclass} - {self._field}")
|
||||
return self._stateclass
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
parent = f"inverter_{self._uid}"
|
||||
return {
|
||||
"identifiers": {
|
||||
(DOMAIN, parent),
|
||||
}
|
||||
}
|
||||
|
||||
@property
|
||||
def entity_category(self):
|
||||
return self._entity_category
|
||||
|
||||
class APSystemsECUSensor(CoordinatorEntity, SensorEntity):
|
||||
|
||||
def __init__(self, coordinator, ecu, field, label=None, icon=None, unit=None, devclass=None, stateclass=None, entity_category=None):
|
||||
|
||||
super().__init__(coordinator)
|
||||
|
||||
self.coordinator = coordinator
|
||||
|
||||
self._ecu = ecu
|
||||
self._field = field
|
||||
self._label = label
|
||||
if not label:
|
||||
self._label = field
|
||||
self._icon = icon
|
||||
self._unit = unit
|
||||
self._devclass = devclass
|
||||
self._stateclass = stateclass
|
||||
self._entity_category = entity_category
|
||||
|
||||
self._name = f"ECU {self._label}"
|
||||
self._state = None
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
return f"{self._ecu.ecu.ecu_id}_{self._field}"
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
return self._devclass
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
_LOGGER.debug(f"State called for {self._field}")
|
||||
return self.coordinator.data.get(self._field)
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
return self._unit
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
|
||||
attrs = {
|
||||
"ecu_id" : self._ecu.ecu.ecu_id,
|
||||
"Firmware" : self._ecu.ecu.firmware,
|
||||
"Timezone" : self._ecu.ecu.timezone,
|
||||
"last_update" : self._ecu.ecu.last_update
|
||||
}
|
||||
return attrs
|
||||
|
||||
@property
|
||||
def state_class(self):
|
||||
_LOGGER.debug(f"State class {self._stateclass} - {self._field}")
|
||||
return self._stateclass
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
parent = f"ecu_{self._ecu.ecu.ecu_id}"
|
||||
return {
|
||||
"identifiers": {
|
||||
(DOMAIN, parent),
|
||||
}
|
||||
}
|
||||
|
||||
@property
|
||||
def entity_category(self):
|
||||
return self._entity_category
|
||||
132
config/custom_components/apsystems_ecur/switch.py
Normal file
132
config/custom_components/apsystems_ecur/switch.py
Normal file
@@ -0,0 +1,132 @@
|
||||
import logging
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity
|
||||
)
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
RELOAD_ICON,
|
||||
POWER_ICON
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(hass, config, add_entities, discovery_info=None):
|
||||
|
||||
ecu = hass.data[DOMAIN].get("ecu")
|
||||
coordinator = hass.data[DOMAIN].get("coordinator")
|
||||
switches = [
|
||||
APSystemsECUQuerySwitch(coordinator, ecu, "query_device",
|
||||
label="Query Device", icon=RELOAD_ICON),
|
||||
APSystemsECUInvertersSwitch(coordinator, ecu, "inverters_online",
|
||||
label="Inverters Online", icon=POWER_ICON),
|
||||
]
|
||||
add_entities(switches)
|
||||
|
||||
class APSystemsECUQuerySwitch(CoordinatorEntity, SwitchEntity):
|
||||
def __init__(self, coordinator, ecu, field, label=None, icon=None):
|
||||
super().__init__(coordinator)
|
||||
self.coordinator = coordinator
|
||||
self._ecu = ecu
|
||||
self._field = field
|
||||
self._label = label
|
||||
if not label:
|
||||
self._label = field
|
||||
self._icon = icon
|
||||
self._name = f"ECU {self._label}"
|
||||
self._state = True
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
return f"{self._ecu.ecu.ecu_id}_{self._field}"
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
parent = f"ecu_{self._ecu.ecu.ecu_id}"
|
||||
return {
|
||||
"identifiers": {
|
||||
(DOMAIN, parent),
|
||||
}
|
||||
}
|
||||
|
||||
@property
|
||||
def entity_category(self):
|
||||
return EntityCategory.CONFIG
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
return self._ecu.querying
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
self._ecu.stop_query()
|
||||
self._state = False
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
self._ecu.start_query()
|
||||
self._state = True
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
class APSystemsECUInvertersSwitch(CoordinatorEntity, SwitchEntity):
|
||||
def __init__(self, coordinator, ecu, field, label=None, icon=None):
|
||||
super().__init__(coordinator)
|
||||
self.coordinator = coordinator
|
||||
self._ecu = ecu
|
||||
self._field = field
|
||||
self._label = label
|
||||
if not label:
|
||||
self._label = field
|
||||
self._icon = icon
|
||||
self._name = f"ECU {self._label}"
|
||||
self._state = True
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
return f"{self._ecu.ecu.ecu_id}_{self._field}"
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
parent = f"ecu_{self._ecu.ecu.ecu_id}"
|
||||
return {
|
||||
"identifiers": {
|
||||
(DOMAIN, parent),
|
||||
}
|
||||
}
|
||||
|
||||
@property
|
||||
def entity_category(self):
|
||||
return EntityCategory.CONFIG
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
return self._ecu.inverters_online
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
self._ecu.inverters_off()
|
||||
self._state = False
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
self._ecu.inverters_on()
|
||||
self._state = True
|
||||
self.schedule_update_ha_state()
|
||||
48
config/custom_components/apsystems_ecur/translations/de.json
Normal file
48
config/custom_components/apsystems_ecur/translations/de.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"host": "ECU IP-Adresse (bitte Verbindungstabelle in der readme prüfen).",
|
||||
"scan_interval": "ECU Abfrage Frequenz in Sekunden (minimum 300 empfohlen).",
|
||||
"CACHE": "Wiederholungen, wenn ECU ausfällt (Bereich zwischen 1 - 5 empfohlen)",
|
||||
"SSID": "SSID angeben (nur für Modelle ECU-R (sunspec) und ECU-C)",
|
||||
"WPA-PSK": "Kennwort angeben (nur für Modelle ECU-R (sunspec) und ECU-C)",
|
||||
"stop_graphs": "Aktualisieren die Diagramme nicht, wenn die Wechselrichter offline sind"
|
||||
},
|
||||
"title": "APsystems ECU Konfiguration"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Es wurde keine ECU unter dieser IP-Adresse gefunden oder life-time energy ist Null.",
|
||||
"no_ecuid": "Es wurde keine ECU ID von der ECU zurückgegeben.",
|
||||
"unknown": "Unbekannter Fehler, bitte Logs überprüfen."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "ECU IP-Adresse (bitte Verbindungstabelle in der readme prüfen).",
|
||||
"scan_interval": "ECU Abfrage Frequenz in Sekunden (minimum 300 empfohlen).",
|
||||
"CACHE": "Wiederholungen, wenn ECU ausfällt (Bereich zwischen 1 - 5 empfohlen)",
|
||||
"SSID": "SSID angeben (nur für Modelle ECU-R (sunspec) und ECU-C)",
|
||||
"WPA-PSK": "Kennwort angeben (nur für Modelle ECU-R (sunspec) und ECU-C)",
|
||||
"stop_graphs": "Aktualisieren die Diagramme nicht, wenn die Wechselrichter offline sind"
|
||||
},
|
||||
"title": "APsystems ECU Optionen"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Es wurde keine ECU unter dieser IP-Adresse gefunden oder life-time energy ist Null.",
|
||||
"no_ecuid": "Es wurde keine ECU ID von der ECU zurückgegeben.",
|
||||
"unknown": "Unbekannter Fehler, bitte Logs überprüfen."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
48
config/custom_components/apsystems_ecur/translations/en.json
Normal file
48
config/custom_components/apsystems_ecur/translations/en.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"host": "ECU IP address (follow connection method table in readme)",
|
||||
"scan_interval": "ECU query interval in seconds (minimum 300 recommended)",
|
||||
"CACHE": "Retries when ECU fails (range between 1 - 5 recommended)",
|
||||
"SSID": "Specify SSID (For ECU-R (sunspec) and ECU-C models only)",
|
||||
"WPA-PSK": "Specify password (For ECU-R (sunspec) and ECU-C models only)",
|
||||
"stop_graphs": "Do not update graphs when inverters are offline"
|
||||
},
|
||||
"title": "APsystems ECU Config"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Can't find ECU at this IP-Address or life-time energy is zero",
|
||||
"no_ecuid": "No ECU ID returned from ECU",
|
||||
"unknown": "Unknown error, see log for details"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "ECU IP address (follow connection method table in readme)",
|
||||
"scan_interval": "ECU query interval in seconds (minimum 300 recommended)",
|
||||
"CACHE": "Retries when ECU fails (range between 1 - 5 recommended)",
|
||||
"SSID": "Specify SSID (For ECU-R (sunspec) and ECU-C models only)",
|
||||
"WPA-PSK": "Specify password (For ECU-R (sunspec) and ECU-C models only)",
|
||||
"stop_graphs": "Do not update graphs when inverters are offline"
|
||||
},
|
||||
"title": "APsystems ECU Options"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Can't find ECU at this IP-Address or life-time energy is zero",
|
||||
"no_ecuid": "No ECU ID returned from ECU",
|
||||
"unknown": "Unknown error, see log for details"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
48
config/custom_components/apsystems_ecur/translations/es.json
Normal file
48
config/custom_components/apsystems_ecur/translations/es.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"host": "Dirección IP de la ECU (Sigue el método para conectarse en la tabla del archivo readme)",
|
||||
"scan_interval": "Intervalo de conexión a la ECU en segundos (Mínimo 300 recomendado)",
|
||||
"CACHE": "Reintentos cuando la ECU falla (Rango entre 1 - 5 recomendado)",
|
||||
"SSID": "Introduce SSID (Solo para modelos ECU-R (sunspec) and ECU-C)",
|
||||
"WPA-PSK": "Introduce contraseña (Solo para modelos ECU-R (sunspec) and ECU-C)",
|
||||
"stop_graphs": "No actualice los gráficos cuando los inversores estén fuera de línea"
|
||||
},
|
||||
"title": "Configuración APsystems ECU"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "No puedo encontrar la ECU en esta dirección IP o la energía life-time es cero",
|
||||
"no_ecuid": "La ECU no ha devuelto ningún ECU ID",
|
||||
"unknown": "Error desconocido, lee el log para mas detalles"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Dirección IP de la ECU (Sigue el método para conectarse en la tabla del archivo readme)",
|
||||
"scan_interval": "Intervalo de conexión a la ECU en segundos (Mínimo 300 recomendado)",
|
||||
"CACHE": "Reintentos cuando la ECU falla (Rango entre 1 - 5 recomendado)",
|
||||
"SSID": "Introduce SSID (Solo para modelos ECU-R (sunspec) and ECU-C)",
|
||||
"WPA-PSK": "Introduce contraseña (Solo para modelos ECU-R (sunspec) and ECU-C)",
|
||||
"stop_graphs": "No actualice los gráficos cuando los inversores estén fuera de línea"
|
||||
},
|
||||
"title": "Configuración APsystems ECU"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "No puedo encontrar la ECU en esta dirección IP o la energía life-time es cero",
|
||||
"no_ecuid": "La ECU no ha devuelto ningún ECU ID",
|
||||
"unknown": "Error desconocido, lee el log para mas detalles"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
49
config/custom_components/apsystems_ecur/translations/fr.json
Normal file
49
config/custom_components/apsystems_ecur/translations/fr.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"host": "Adresse IP ECU (Voir “Prerequisites” dans le fichier Readme)",
|
||||
"scan_interval": "Intervalle des requêtes sur l'ECU en secondes (Min de 300 recommandées)",
|
||||
"CACHE": "Nombre de tentatives en cas d'échec de communication (1 à 5 recommandée)",
|
||||
"SSID": "Spécifier le SSID (Pour ECU-R (Sunspec) et ECU-C seulement)",
|
||||
"WPA-PSK": "Spécifier le mot de passe (Pour ECU-R (Sunspec) et ECU-C seulement)",
|
||||
"stop_graphs": "Ne pas mettre à jour les graphiques lorsque les onduleurs sont hors ligne"
|
||||
},
|
||||
"title": "Configuration ECU APsystems"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Ne trouve pas d'ECU à cette adresse IP ou énergie totale produite nulle",
|
||||
"no_ecuid": "Pas d'ID ECU retourné pour cet ECU",
|
||||
"unknown": "Erreur inconnue, veuillez consulter le journal des logs pour plus de détails"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Adresse IP ECU (Voir “Prerequisites” dans le fichier Readme)",
|
||||
"scan_interval": "Intervalle des requêtes sur l'ECU en secondes (Min de 300 recommandées)",
|
||||
"CACHE": "Nombre de tentatives en cas d'échec de communication (1 à 5 recommandée)",
|
||||
"SSID": "Spécifier le SSID (Pour ECU-R (Sunspec) et ECU-C seulement)",
|
||||
"WPA-PSK": "Spécifier le mot de passe (Pour ECU-R (Sunspec) et ECU-C seulement)",
|
||||
"stop_graphs": "Ne pas mettre à jour les graphiques lorsque les onduleurs sont hors ligne"
|
||||
},
|
||||
"title": "Options ECU APsystems"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Ne trouve pas d'ECU à cette adresse IP ou énergie totale produite nulle",
|
||||
"inverter": "Type d'onduleur inconnu, veuillez vérifier les journaux",
|
||||
"no_ecuid": "Pas d'ID ECU retourné pour cet ECU",
|
||||
"unknown": "Erreur inconnue, veuillez consulter le journal des logs pour plus de détails"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
48
config/custom_components/apsystems_ecur/translations/nl.json
Normal file
48
config/custom_components/apsystems_ecur/translations/nl.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"host": "ECU IP-adres (volg de connectie methode tabel in de readme)",
|
||||
"scan_interval": "ECU query interval in seconden (minimum 300 aanbevolen)",
|
||||
"CACHE": "Pogingen als de ECU niet reageert (tussen 1 - 5 aanbevolen)",
|
||||
"SSID": "Specificeer SSID (Alleen voor ECU-R (sunspec) en ECU-C modellen)",
|
||||
"WPA-PSK": "Specificeer wachtwoord (voor ECU-R (sunspec) en ECU-C modellen)",
|
||||
"stop_graphs": "Werk grafieken niet bij als de omvormers offline zijn"
|
||||
},
|
||||
"title": "APsystems ECU Configuratie"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Kan de ECU niet vinden op dit IP-adres of life-time energy is nul",
|
||||
"no_ecuid": "Geen ECU ID ontvangen",
|
||||
"unknown": "Onbekende fout, zie het log for details"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "ECU IP-adres (volg de connectie methode tabel in de readme)",
|
||||
"scan_interval": "ECU query interval in seconden (minimum 300 aanbevolen)",
|
||||
"CACHE": "Pogingen als de ECU niet reageert (tussen 1 - 5 aanbevolen)",
|
||||
"SSID": "Specificeer SSID (Alleen voor ECU-R (sunspec) en ECU-C modellen)",
|
||||
"WPA-PSK": "Specificeer wachtwoord (voor ECU-R (sunspec) en ECU-C modellen)",
|
||||
"stop_graphs": "Werk grafieken niet bij als de omvormers offline zijn"
|
||||
},
|
||||
"title": "APsystems ECU Opties"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Kan de ECU niet vinden op dit IP-adres of life-time energy is nul",
|
||||
"no_ecuid": "Geen ECU ID ontvangen",
|
||||
"unknown": "Onbekende fout, zie het log voor details"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user