diff --git a/.gitignore b/.gitignore index 07beb18..918ef10 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ venv .venv core.* *.iml -.misc \ No newline at end of file +.misc +.idea +.vscode diff --git a/custom_components/evcc_intg/__init__.py b/custom_components/evcc_intg/__init__.py index 45472bb..f0a00d8 100644 --- a/custom_components/evcc_intg/__init__.py +++ b/custom_components/evcc_intg/__init__.py @@ -16,7 +16,7 @@ JSONKEY_STATISTICS_365D, JSONKEY_STATISTICS_30D, ) -from custom_components.evcc_intg.pyevcc_ha.keys import Tag, EP_TYPE, _camel_to_snake +from custom_components.evcc_intg.pyevcc_ha.keys import Tag, EP_TYPE, camel_to_snake from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant, Event, SupportsResponse @@ -71,10 +71,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): config_entry.add_update_listener(async_reload_entry) # initialize our service... - service = EvccService(hass, config_entry, coordinator) - hass.services.async_register(DOMAIN, SERVICE_SET_LOADPOINT_PLAN, service.set_loadpoint_plan, + services = EvccService(hass, config_entry, coordinator) + hass.services.async_register(DOMAIN, SERVICE_SET_LOADPOINT_PLAN, services.set_loadpoint_plan, supports_response=SupportsResponse.OPTIONAL) - hass.services.async_register(DOMAIN, SERVICE_SET_VEHICLE_PLAN, service.set_vehicle_plan, + hass.services.async_register(DOMAIN, SERVICE_SET_VEHICLE_PLAN, services.set_vehicle_plan, supports_response=SupportsResponse.OPTIONAL) # Do we need to patch something?! @@ -109,7 +109,7 @@ async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> class EvccDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, config_entry): - _LOGGER.debug(f"starting evcc_intg for: {config_entry}") + _LOGGER.debug(f"starting evcc_intg for: {config_entry.options}\n{config_entry.data}") lang = hass.config.language.lower() self.name = config_entry.title self.bridge = EvccApiBridge(host=config_entry.options.get(CONF_HOST, config_entry.data.get(CONF_HOST)), @@ -136,6 +136,14 @@ def __init__(self, hass: HomeAssistant, config_entry): # config_entry only need for providing the '_device_info_dict'... self._config_entry = config_entry + # attribute creation + self._cost_type = None + self._currency = "€" + self._device_info_dict = {} + self._loadpoint = {} + self._vehicle = {} + self._version = None + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) # Callable[[Event], Any] @@ -170,7 +178,6 @@ async def read_evcc_config_on_startup(self): "sw_version": self._version } - self._vehicle = {} for a_veh_name in initdata[JSONKEY_VEHICLES]: a_veh = initdata[JSONKEY_VEHICLES][a_veh_name] if "capacity" in a_veh: @@ -184,7 +191,6 @@ async def read_evcc_config_on_startup(self): "capacity": None } - self._loadpoint = {} api_index = 1 for a_loadpoint in initdata[JSONKEY_LOADPOINTS]: phaseSwitching = False @@ -213,8 +219,6 @@ async def read_evcc_config_on_startup(self): self._currency = initdata["currency"] if self._currency == "EUR": self._currency = "€" - else: - self._currency = "€" _LOGGER.debug( f"read_evcc_config: LPs: {len(self._loadpoint)} VEHs: {len(self._vehicle)} CT: '{self._cost_type}' CUR: {self._currency}") @@ -345,9 +349,10 @@ async def async_press_tag(self, tag: Tag, value, idx: str = None, entity: Entity return result - async def async_write_tag(self, tag: Tag, value, idx: str = None, entity: Entity = None) -> dict: + async def async_write_tag(self, tag: Tag, value, idx_str: str = None, entity: Entity = None) -> dict: """Update single data""" - result = await self.bridge.write_tag(tag, value, idx) + idx = int(idx_str) + result = await self.bridge.write_tag(tag, value, idx_str) _LOGGER.debug(f"write result: {result}") if tag.key not in result or result[tag.key] is None: @@ -374,7 +379,8 @@ async def async_write_tag(self, tag: Tag, value, idx: str = None, entity: Entity return result - def _convert_time(self, value: str): + @staticmethod + def _convert_time(value: str): if value is not None and len(value) > 0: if "0001-01-01T00:00:00Z" == value: return None @@ -390,6 +396,18 @@ def _convert_time(self, value: str): else: return None + @property + def system_id(self): + return self._system_id + + @property + def currency(self): + return self._currency + + @property + def device_info_dict(self): + return self._device_info_dict + class EvccBaseEntity(Entity): _attr_should_poll = False @@ -397,9 +415,13 @@ class EvccBaseEntity(Entity): _attr_name_addon = None def __init__(self, coordinator: EvccDataUpdateCoordinator, description: EntityDescription) -> None: - self.tag = description.tag + if hasattr(description, "tag"): + self.tag = description.tag + else: + self.tag = None + self.idx = None - if hasattr(description, "idx") and description.idx is not None: + if hasattr(description, "idx"): self.idx = description.idx else: self.idx = None @@ -409,24 +431,26 @@ def __init__(self, coordinator: EvccDataUpdateCoordinator, description: EntityDe else: self._attr_translation_key = description.key.lower() - if hasattr(description, "name_addon") and description.name_addon is not None: + if hasattr(description, "name_addon"): self._attr_name_addon = description.name_addon + else: + self._attr_name_addon = None if hasattr(description, "native_unit_of_measurement") and description.native_unit_of_measurement is not None: if "@@@" in description.native_unit_of_measurement: description.native_unit_of_measurement = description.native_unit_of_measurement.replace("@@@", - coordinator._currency) + coordinator.currency) self.entity_description = description self.coordinator = coordinator - self.entity_id = f"{DOMAIN}.{self.coordinator._system_id}_{_camel_to_snake(description.key)}" + self.entity_id = f"{DOMAIN}.{self.coordinator.system_id}_{camel_to_snake(description.key)}" def _name_internal(self, device_class_name: str | None, platform_translations: dict[str, Any], ) -> str | UndefinedType | None: tmp = super()._name_internal(device_class_name, platform_translations) if tmp is not None and "@@@" in tmp: - tmp = tmp.replace("@@@", self.coordinator._currency) + tmp = tmp.replace("@@@", self.coordinator.currency) if self._attr_name_addon is not None: return f"{self._attr_name_addon} {tmp}" else: @@ -434,7 +458,7 @@ def _name_internal(self, device_class_name: str | None, @property def device_info(self) -> dict: - return self.coordinator._device_info_dict + return self.coordinator.device_info_dict @property def available(self): diff --git a/custom_components/evcc_intg/config_flow.py b/custom_components/evcc_intg/config_flow.py index f96af31..73edb99 100644 --- a/custom_components/evcc_intg/config_flow.py +++ b/custom_components/evcc_intg/config_flow.py @@ -32,8 +32,13 @@ async def async_step_user(self, user_input=None): self._errors = {} if user_input is not None: - if user_input[CONF_HOST].startswith(("http://", "https://")): - user_input[CONF_HOST] = user_input[CONF_HOST].split("//")[1] + if not user_input[CONF_HOST].startswith(("http://", "https://")): + if ":" in user_input[CONF_HOST]: + # we have NO schema but a colon, so assume http + user_input[CONF_HOST] = "http://" + user_input[CONF_HOST] + else: + # https otherwise + user_input[CONF_HOST] = "https://" + user_input[CONF_HOST] while user_input[CONF_HOST].endswith(("/", " ")): user_input[CONF_HOST] = user_input[CONF_HOST][:-1] @@ -52,6 +57,8 @@ async def async_step_user(self, user_input=None): user_input[CONF_SCAN_INTERVAL] = 15 user_input[CONF_INCLUDE_EVCC] = False + user_input = user_input or {} + return self.async_show_form( step_id="user", data_schema=vol.Schema({ diff --git a/custom_components/evcc_intg/pyevcc_ha/__init__.py b/custom_components/evcc_intg/pyevcc_ha/__init__.py index 4cc0210..e113c35 100644 --- a/custom_components/evcc_intg/pyevcc_ha/__init__.py +++ b/custom_components/evcc_intg/pyevcc_ha/__init__.py @@ -17,6 +17,38 @@ _LOGGER: logging.Logger = logging.getLogger(__package__) +async def do_request(method: Callable) -> dict: + async with method as res: + try: + if res.status == 200: + try: + return await res.json() + + except JSONDecodeError as json_exc: + _LOGGER.warning(f"APP-API: JSONDecodeError while 'await res.json(): {json_exc}") + + except ClientResponseError as io_exc: + _LOGGER.warning(f"APP-API: ClientResponseError while 'await res.json(): {io_exc}") + + elif res.status == 500 and int(res.headers['Content-Length']) > 0: + try: + r_json = await res.json() + return {"err": r_json} + except JSONDecodeError as json_exc: + _LOGGER.warning(f"APP-API: JSONDecodeError while 'res.status == 500 res.json(): {json_exc}") + + except ClientResponseError as io_exc: + _LOGGER.warning(f"APP-API: ClientResponseError while 'res.status == 500 res.json(): {io_exc}") + + else: + _LOGGER.warning(f"APP-API: write_value failed with http-status {res.status}") + + except ClientResponseError as io_exc: + _LOGGER.warning(f"APP-API: write_value failed cause: {io_exc}") + + return {} + + class EvccApiBridge: def __init__(self, host: str, web_session, lang: str = "en") -> None: self.host = host @@ -56,9 +88,9 @@ async def read_all(self) -> dict: async def read_all_data(self) -> dict: _LOGGER.info(f"going to read all data from evcc@{self.host}") - req = f"http://{self.host}/api/state" + req = f"{self.host}/api/state" _LOGGER.debug(f"GET request: {req}") - json_resp = await self.do_request(method = self.web_session.get(req)) + json_resp = await do_request(method = self.web_session.get(req)) if len(json_resp) is not None: self._LAST_FULL_STATE_UPDATE_TS = time() @@ -71,9 +103,9 @@ async def read_all_data(self) -> dict: async def read_frequent_data(self) -> dict: # make sure that idx is really an int... _LOGGER.info(f"going to read all frequent_data from evcc@{self.host}") - req = f"http://{self.host}/api/state{STATE_QUERY}" + req = f"{self.host}/api/state{STATE_QUERY}" _LOGGER.debug(f"GET request: {req}") - return await self.do_request(method = self.web_session.get(req)) + return await do_request(method = self.web_session.get(req)) async def press_tag(self, tag: Tag, value, idx:str = None) -> dict: ret = {} @@ -110,17 +142,17 @@ async def press_loadpoint_key(self, lp_idx, write_key, value) -> dict: _LOGGER.info(f"going to press a button with payload '{value}' for key '{write_key}' to evcc-loadpoint{lp_idx}@{self.host}") if value is None: if write_key == Tag.DETECTVEHICLE.write_key: - req = f"http://{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{lp_idx}/vehicle" + req = f"{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{lp_idx}/vehicle" _LOGGER.debug(f"PATCH request: {req}") - r_json = await self.do_request(method = self.web_session.patch(req)) + r_json = await do_request(method = self.web_session.patch(req)) else: - req = f"http://{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{lp_idx}/{write_key}" + req = f"{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{lp_idx}/{write_key}" _LOGGER.debug(f"DELETE request: {req}") - r_json = await self.do_request(method = self.web_session.delete(req)) + r_json = await do_request(method = self.web_session.delete(req)) else: - req = f"http://{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{lp_idx}/{write_key}/{value}" + req = f"{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{lp_idx}/{write_key}/{value}" _LOGGER.debug(f"POST request: {req}") - r_json = await self.do_request(method = self.web_session.post(req)) + r_json = await do_request(method = self.web_session.post(req)) if r_json is not None and len(r_json) > 0: if "result" in r_json: @@ -139,15 +171,15 @@ async def press_vehicle_key(self, vehicle_id:str, write_key, value) -> dict: r_json = None if value is None: if write_key == Tag.VEHICLEPLANSDELETE.write_key: - req = f"http://{self.host}/api/{EP_TYPE.VEHICLES.value}/{vehicle_id}/{write_key}" + req = f"{self.host}/api/{EP_TYPE.VEHICLES.value}/{vehicle_id}/{write_key}" _LOGGER.debug(f"DELETE request: {req}") - r_json = await self.do_request(method = self.web_session.delete(req)) + r_json = await do_request(method = self.web_session.delete(req)) else: pass else: - req = f"http://{self.host}/api/{EP_TYPE.VEHICLES.value}/{vehicle_id}/{write_key}/{value}" + req = f"{self.host}/api/{EP_TYPE.VEHICLES.value}/{vehicle_id}/{write_key}/{value}" _LOGGER.debug(f"POST request: {req}") - r_json = await self.do_request(method = self.web_session.post(req)) + r_json = await do_request(method = self.web_session.post(req)) if r_json is not None and len(r_json) > 0: if "result" in r_json: @@ -196,13 +228,13 @@ async def write_site_key(self, write_key, value) -> dict: _LOGGER.info(f"going to write '{value}' for key '{write_key}' to evcc-site@{self.host}") r_json = None if value is None: - req = f"http://{self.host}/api/{write_key}" + req = f"{self.host}/api/{write_key}" _LOGGER.debug(f"DELETE request: {req}") - r_json = await self.do_request(method = self.web_session.delete(req)) + r_json = await do_request(method = self.web_session.delete(req)) else: - req = f"http://{self.host}/api/{write_key}/{value}" + req = f"{self.host}/api/{write_key}/{value}" _LOGGER.debug(f"POST request: {req}") - r_json = await self.do_request(method = self.web_session.post(req)) + r_json = await do_request(method = self.web_session.post(req)) if r_json is not None and len(r_json) > 0: if "result" in r_json: @@ -221,13 +253,13 @@ async def write_loadpoint_key(self, lp_idx, write_key, value) -> dict: _LOGGER.info(f"going to write '{value}' for key '{write_key}' to evcc-loadpoint{lp_idx}@{self.host}") r_json = None if value is None: - req = f"http://{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{lp_idx}/{write_key}" + req = f"{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{lp_idx}/{write_key}" _LOGGER.debug(f"DELETE request: {req}") - r_json = await self.do_request(method = self.web_session.delete(req)) + r_json = await do_request(method = self.web_session.delete(req)) else: - req = f"http://{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{lp_idx}/{write_key}/{value}" + req = f"{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{lp_idx}/{write_key}/{value}" _LOGGER.debug(f"POST request: {req}") - r_json = await self.do_request(method = self.web_session.post(req)) + r_json = await do_request(method = self.web_session.post(req)) if r_json is not None and len(r_json) > 0: if "result" in r_json: @@ -243,9 +275,9 @@ async def write_vehicle_key(self, vehicle_id:str, write_key, value) -> dict: value = str(value) _LOGGER.info(f"going to write '{value}' for key '{write_key}' to evcc-vehicle{vehicle_id}@{self.host}") - req = f"http://{self.host}/api/{EP_TYPE.VEHICLES.value}/{vehicle_id}/{write_key}/{value}" + req = f"{self.host}/api/{EP_TYPE.VEHICLES.value}/{vehicle_id}/{write_key}/{value}" _LOGGER.debug(f"POST request: {req}") - r_json = await self.do_request(method = self.web_session.post(req)) + r_json = await do_request(method = self.web_session.post(req)) if r_json is not None and len(r_json) > 0: if "result" in r_json: @@ -258,9 +290,9 @@ async def write_loadpoint_plan(self, idx:str, energy:str, rfc_date:str): # before we can write something to the vehicle endpoints, we must know the vehicle_id! # -> so we have to grab from the loadpoint the current vehicle! try: - req = f"http://{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{idx}/plan/energy/{energy}/{rfc_date}" + req = f"{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{idx}/plan/energy/{energy}/{rfc_date}" _LOGGER.debug(f"POST request: {req}") - r_json = await self.do_request(method = self.web_session.post(req)) + r_json = await do_request(method = self.web_session.post(req)) if r_json is not None and len(r_json) > 0: if "result" in r_json: self._LAST_FULL_STATE_UPDATE_TS = 0 @@ -279,9 +311,9 @@ async def write_vehicle_plan_for_loadpoint_index(self, idx:str, soc:str, rfc_dat int_idx = int(idx) - 1 vehicle_id = self._data[JSONKEY_LOADPOINTS][int_idx][Tag.VEHICLENAME.key] if vehicle_id is not None: - req = f"http://{self.host}/api/{EP_TYPE.VEHICLES.value}/{vehicle_id}/plan/soc/{soc}/{rfc_date}" + req = f"{self.host}/api/{EP_TYPE.VEHICLES.value}/{vehicle_id}/plan/soc/{soc}/{rfc_date}" _LOGGER.debug(f"POST request: {req}") - r_json = await self.do_request(method = self.web_session.post(req)) + r_json = await do_request(method = self.web_session.post(req)) if r_json is not None and len(r_json) > 0: if "result" in r_json: self._LAST_FULL_STATE_UPDATE_TS = 0 @@ -291,34 +323,3 @@ async def write_vehicle_plan_for_loadpoint_index(self, idx:str, soc:str, rfc_dat except Exception as err: _LOGGER.info(f"could not find a connected vehicle at loadpoint: {idx}") - - async def do_request(self, method: Callable) -> dict: - async with method as res: - try: - if res.status == 200: - try: - return await res.json() - - except JSONDecodeError as json_exc: - _LOGGER.warning(f"APP-API: JSONDecodeError while 'await res.json(): {json_exc}") - - except ClientResponseError as io_exc: - _LOGGER.warning(f"APP-API: ClientResponseError while 'await res.json(): {io_exc}") - - elif res.status == 500 and int(res.headers['Content-Length']) > 0: - try: - r_json = await res.json() - return {"err": r_json} - except JSONDecodeError as json_exc: - _LOGGER.warning(f"APP-API: JSONDecodeError while 'res.status == 500 res.json(): {json_exc}") - - except ClientResponseError as io_exc: - _LOGGER.warning(f"APP-API: ClientResponseError while 'res.status == 500 res.json(): {io_exc}") - - else: - _LOGGER.warning(f"APP-API: write_value failed with http-status {res.status}") - - except ClientResponseError as io_exc: - _LOGGER.warning(f"APP-API: write_value failed cause: {io_exc}") - - return {} diff --git a/custom_components/evcc_intg/pyevcc_ha/keys.py b/custom_components/evcc_intg/pyevcc_ha/keys.py index 383cb2e..794b5ea 100644 --- a/custom_components/evcc_intg/pyevcc_ha/keys.py +++ b/custom_components/evcc_intg/pyevcc_ha/keys.py @@ -27,8 +27,7 @@ CC_P1: Final = re.compile(r"(.)([A-Z][a-z]+)") CC_P2: Final = re.compile(r"([a-z0-9])([A-Z])") -@staticmethod -def _camel_to_snake(a_key: str): +def camel_to_snake(a_key: str): if a_key.lower().endswith("kwh"): a_key = a_key[:-3] + "_kwh" a_key = re.sub(CC_P1, r'\1_\2', a_key) @@ -353,4 +352,4 @@ def __str__(self): STAT30AVGCO2 = ApiKey(entity_key="stat30AvgCo2", key="avgCo2", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_30D) STAT30AVGPRICE = ApiKey(entity_key="stat30AvgPrice", key="avgPrice", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_30D) STAT30CHARGEDKWH = ApiKey(entity_key="stat30ChargedKWh", key="chargedKWh", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_30D) - STAT30SOLARPERCENTAGE = ApiKey(entity_key="stat30SolarPercentage", key="solarPercentage", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_30D) \ No newline at end of file + STAT30SOLARPERCENTAGE = ApiKey(entity_key="stat30SolarPercentage", key="solarPercentage", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_30D) diff --git a/requirements.txt b/requirements.txt index 5be84db..c42925f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ -homeassistant>=2024.8.2 \ No newline at end of file +homeassistant>=2024.8.2 +aiohttp~=3.11.11 +voluptuous~=0.15.2