From d58d1c8be4c60fc05ef1d8782aefeaa3604f1de3 Mon Sep 17 00:00:00 2001 From: Augustas Cirba Date: Sat, 25 Jan 2025 16:13:42 +0200 Subject: [PATCH 01/10] HA deprecation fix #2 --- .../eldes_alarm/alarm_control_panel.py | 6 +++--- custom_components/eldes_alarm/core/eldes_cloud.py | 14 ++++++-------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/custom_components/eldes_alarm/alarm_control_panel.py b/custom_components/eldes_alarm/alarm_control_panel.py index 781e6e1..361c40c 100644 --- a/custom_components/eldes_alarm/alarm_control_panel.py +++ b/custom_components/eldes_alarm/alarm_control_panel.py @@ -79,7 +79,7 @@ async def async_alarm_disarm(self, code: str | None = None) -> None: await self.client.set_alarm( ALARM_MODES["DISARM"], self.imei, - self.entity_index + self.data['internalId'] ) except Exception as ex: _LOGGER.error("Failed to change state: %s", ex) @@ -98,7 +98,7 @@ async def async_alarm_arm_away(self, code: str | None = None) -> None: await self.client.set_alarm( ALARM_MODES["ARM_AWAY"], self.imei, - self.entity_index + self.data['internalId'] ) except Exception as ex: _LOGGER.error("Failed to change state: %s", ex) @@ -117,7 +117,7 @@ async def async_alarm_arm_home(self, code: str | None = None) -> None: await self.client.set_alarm( ALARM_MODES["ARM_HOME"], self.imei, - self.entity_index + self.data['internalId'] ) except Exception as ex: _LOGGER.error("Failed to change state: %s", ex) diff --git a/custom_components/eldes_alarm/core/eldes_cloud.py b/custom_components/eldes_alarm/core/eldes_cloud.py index 72a2433..dbe5c15 100644 --- a/custom_components/eldes_alarm/core/eldes_cloud.py +++ b/custom_components/eldes_alarm/core/eldes_cloud.py @@ -4,10 +4,8 @@ import logging import aiohttp -from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelState ) from ..const import API_URL, API_PATHS @@ -15,9 +13,9 @@ _LOGGER = logging.getLogger(__name__) ALARM_STATES_MAP = { - "DISARMED": STATE_ALARM_DISARMED, - "ARMED": STATE_ALARM_ARMED_AWAY, - "ARMSTAY": STATE_ALARM_ARMED_HOME + "DISARMED": AlarmControlPanelState.DISARMED, + "ARMED": AlarmControlPanelState.ARMED_AWAY, + "ARMSTAY": AlarmControlPanelState.ARMED_HOME } @@ -149,7 +147,7 @@ async def get_device_partitions(self, imei): # Replace Eldes state with HA state name for partitionIndex, _ in enumerate(partitions): - partitions[partitionIndex]["state"] = ALARM_STATES_MAP[partitions[partitionIndex].get("state", STATE_ALARM_DISARMED)] + partitions[partitionIndex]["state"] = ALARM_STATES_MAP[partitions[partitionIndex].get("state", AlarmControlPanelState.DISARMED)] _LOGGER.debug( "get_device_partitions result: %s", From 6519e210edd694a672370b3e0f088bf1174f731a Mon Sep 17 00:00:00 2001 From: Augustas Cirba Date: Fri, 18 Apr 2025 17:47:21 +0300 Subject: [PATCH 02/10] v2 components with APi fixes --- custom_components/eldes_alarm/__init__.py | 32 +-- .../eldes_alarm/alarm_control_panel.py | 54 +++-- .../eldes_alarm/binary_sensor.py | 32 ++- custom_components/eldes_alarm/config_flow.py | 11 +- custom_components/eldes_alarm/const.py | 1 + .../eldes_alarm/core/eldes_cloud.py | 214 ++++++------------ custom_components/eldes_alarm/manifest.json | 4 +- custom_components/eldes_alarm/sensor.py | 118 ++++------ custom_components/eldes_alarm/switch.py | 93 ++++---- 9 files changed, 231 insertions(+), 328 deletions(-) diff --git a/custom_components/eldes_alarm/__init__.py b/custom_components/eldes_alarm/__init__.py index d096524..55a9f74 100644 --- a/custom_components/eldes_alarm/__init__.py +++ b/custom_components/eldes_alarm/__init__.py @@ -5,6 +5,7 @@ from http import HTTPStatus import aiohttp +from aiohttp import ClientError, ClientResponseError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_SCAN_INTERVAL @@ -49,10 +50,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await eldes_client.login() - except (asyncio.TimeoutError, aiohttp.ClientError) as ex: - if ex.status == HTTPStatus.UNAUTHORIZED: + except (asyncio.TimeoutError, ClientResponseError) as ex: + if isinstance(ex, ClientResponseError) and ex.status == HTTPStatus.UNAUTHORIZED: raise ConfigEntryAuthFailed from ex - raise ConfigEntryNotReady from ex except Exception as ex: _LOGGER.error("Failed to setup Eldes: %s", ex) @@ -61,12 +61,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_data(): try: return await async_get_devices(hass, entry, eldes_client) - except: - pass - - try: - await eldes_client.login() - return await async_get_devices(hass, entry, eldes_client) + except ClientResponseError as ex: + if ex.status == HTTPStatus.UNAUTHORIZED: + _LOGGER.warning("Token expired or invalid. Attempting full re-login.") + try: + await eldes_client.login() + return await async_get_devices(hass, entry, eldes_client, skip_token_renew=True) + except Exception as retry_ex: + _LOGGER.exception("Failed to recover after re-login: %s", retry_ex) + raise UpdateFailed(retry_ex) from retry_ex + raise UpdateFailed(ex) from ex except Exception as ex: _LOGGER.exception("Unknown error occurred during Eldes update request: %s", ex) raise UpdateFailed(ex) from ex @@ -86,24 +90,21 @@ async def async_update_data(): DATA_DEVICES: [], } - # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() - - # Setup components await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_get_devices(hass: HomeAssistant, entry: ConfigEntry, eldes_client: EldesCloud): +async def async_get_devices(hass: HomeAssistant, entry: ConfigEntry, eldes_client: EldesCloud, skip_token_renew: bool = False): """Fetch data from Eldes API.""" events_list_size = entry.options.get(CONF_EVENTS_LIST_SIZE, DEFAULT_EVENTS_LIST_SIZE) - await eldes_client.renew_token() + if not skip_token_renew: + await eldes_client.renew_token() devices = await eldes_client.get_devices() - # Retrieve additional device info, partitions and outputs for device in devices: device["info"] = await eldes_client.get_device_info(device["imei"]) device["partitions"] = await eldes_client.get_device_partitions(device["imei"]) @@ -112,7 +113,6 @@ async def async_get_devices(hass: HomeAssistant, entry: ConfigEntry, eldes_clien device["events"] = await eldes_client.get_events(events_list_size) hass.data[DOMAIN][entry.entry_id][DATA_DEVICES] = devices - return devices diff --git a/custom_components/eldes_alarm/alarm_control_panel.py b/custom_components/eldes_alarm/alarm_control_panel.py index 361c40c..3d24b3c 100644 --- a/custom_components/eldes_alarm/alarm_control_panel.py +++ b/custom_components/eldes_alarm/alarm_control_panel.py @@ -4,73 +4,81 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, - AlarmControlPanelState + AlarmControlPanelState, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DATA_CLIENT, DATA_COORDINATOR, DOMAIN, - ALARM_MODES + ALARM_MODES, ) -from . import EldesZoneEntity _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities): - """Set up the Eldes sensor platform.""" + """Set up the Eldes alarm control panel platform.""" client = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] entities = [] - for deviceIndex, _ in enumerate(coordinator.data): - for partitionIndex, _ in enumerate(coordinator.data[deviceIndex]["partitions"]): - entities.append(EldesAlarmPanel(client, coordinator, deviceIndex, partitionIndex)) + for device_index, device in enumerate(coordinator.data): + for partition_index, _ in enumerate(device["partitions"]): + entities.append(EldesAlarmPanel(client, coordinator, device_index, partition_index)) async_add_entities(entities) -class EldesAlarmPanel(EldesZoneEntity, AlarmControlPanelEntity): +class EldesAlarmPanel(CoordinatorEntity, AlarmControlPanelEntity): """Representation of an Eldes Alarm.""" _attr_supported_features = ( - AlarmControlPanelEntityFeature.ARM_AWAY - | AlarmControlPanelEntityFeature.ARM_HOME + AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_HOME ) _attr_code_arm_required = False + def __init__(self, client, coordinator, device_index, partition_index): + super().__init__(coordinator) + self.client = client + self.device_index = device_index + self.partition_index = partition_index + + @property + def imei(self): + return self.coordinator.data[self.device_index].get("imei") + + @property + def data(self): + return self.coordinator.data[self.device_index]["partitions"][self.partition_index] + @property def unique_id(self): - """Return the unique id.""" return f"{self.imei}_zone_{self.data['internalId']}" @property def name(self): - """Return the name of the zone.""" return self.data["name"] @property def state(self): - """Return the state of the alarm.""" return self.data["state"] @property def extra_state_attributes(self): - """Return the state attributes.""" return { "armed": self.data["armed"], "armStay": self.data["armStay"], "state": self.data["state"], - "hasUnacceptedPartitionAlarms": self.data["hasUnacceptedPartitionAlarms"] + "hasUnacceptedPartitionAlarms": self.data["hasUnacceptedPartitionAlarms"], } async def async_alarm_disarm(self, code: str | None = None) -> None: - """Send disarm command.""" - current_state = self.data["state"] - + current_state = self.state self.data["state"] = AlarmControlPanelState.DISARMING self.async_write_ha_state() @@ -87,9 +95,7 @@ async def async_alarm_disarm(self, code: str | None = None) -> None: self.async_write_ha_state() async def async_alarm_arm_away(self, code: str | None = None) -> None: - """Send arm away command.""" - current_state = self.data["state"] - + current_state = self.state self.data["state"] = AlarmControlPanelState.ARMING self.async_write_ha_state() @@ -106,9 +112,7 @@ async def async_alarm_arm_away(self, code: str | None = None) -> None: self.async_write_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: - """Send arm night command.""" - current_state = self.data["state"] - + current_state = self.state self.data["state"] = AlarmControlPanelState.ARMING self.async_write_ha_state() diff --git a/custom_components/eldes_alarm/binary_sensor.py b/custom_components/eldes_alarm/binary_sensor.py index e282be1..0fec601 100644 --- a/custom_components/eldes_alarm/binary_sensor.py +++ b/custom_components/eldes_alarm/binary_sensor.py @@ -1,16 +1,19 @@ """Support for Eldes sensors.""" import logging -from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorDeviceClass, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DATA_CLIENT, DATA_COORDINATOR, - DOMAIN + DOMAIN, ) -from . import EldesDeviceEntity _LOGGER = logging.getLogger(__name__) @@ -27,25 +30,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e async_add_entities(entities) -class EldesConnectionStatusBinarySensor(EldesDeviceEntity, BinarySensorEntity): - """Class for the connection status sensor.""" +class EldesConnectionStatusBinarySensor(CoordinatorEntity, BinarySensorEntity): + """Class for the Eldes connection status sensor.""" + + def __init__(self, client, coordinator, device_index): + super().__init__(coordinator) + self.client = client + self.device_index = device_index + + @property + def imei(self): + return self.coordinator.data[self.device_index].get("imei") + + @property + def data(self): + return self.coordinator.data[self.device_index] @property def unique_id(self): - """Return a unique identifier for this entity.""" return f"{self.imei}_connection_status" @property def name(self): - """Return the name of the sensor.""" return f"{self.data['info']['model']} Connection Status" @property def is_on(self): - """Return true if sensor is on.""" return self.data["info"].get("online", False) @property def device_class(self): - """Return the class of this sensor.""" return BinarySensorDeviceClass.CONNECTIVITY diff --git a/custom_components/eldes_alarm/config_flow.py b/custom_components/eldes_alarm/config_flow.py index 0dba9b6..b2f9d2f 100644 --- a/custom_components/eldes_alarm/config_flow.py +++ b/custom_components/eldes_alarm/config_flow.py @@ -5,7 +5,7 @@ import voluptuous as vol from http import HTTPStatus -from homeassistant import config_entries, core, exceptions +from homeassistant import config_entries from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_SCAN_INTERVAL @@ -40,7 +40,7 @@ async def async_step_user(self, user_input=None): if user_input is not None: self._username = user_input[CONF_USERNAME] self._password = user_input[CONF_PASSWORD] - unique_id = user_input[CONF_USERNAME].lower() + unique_id = self._username.lower() await self.async_set_unique_id(unique_id) session = async_get_clientsession(self.hass) @@ -48,8 +48,8 @@ async def async_step_user(self, user_input=None): try: await eldes_client.login() - except (asyncio.TimeoutError, aiohttp.ClientError) as err: - if err.status == HTTPStatus.UNAUTHORIZED: + except (asyncio.TimeoutError, aiohttp.ClientResponseError) as err: + if isinstance(err, aiohttp.ClientResponseError) and err.status == HTTPStatus.UNAUTHORIZED: errors["base"] = "invalid_auth" else: errors["base"] = "cannot_connect" @@ -59,12 +59,11 @@ async def async_step_user(self, user_input=None): else: if not self._reauth_entry: return self.async_create_entry( - title=user_input[CONF_USERNAME], data=user_input + title=self._username, data=user_input ) self.hass.config_entries.async_update_entry( self._reauth_entry, data=user_input, unique_id=unique_id ) - # Reload the config entry otherwise devices will remain unavailable self.hass.async_create_task( self.hass.config_entries.async_reload(self._reauth_entry.entry_id) ) diff --git a/custom_components/eldes_alarm/const.py b/custom_components/eldes_alarm/const.py index 8509f36..2dca768 100644 --- a/custom_components/eldes_alarm/const.py +++ b/custom_components/eldes_alarm/const.py @@ -11,6 +11,7 @@ DEFAULT_SCAN_INTERVAL = 30 DEFAULT_EVENTS_LIST_SIZE = 10 +DEFAULT_OUTPUT_ICON = "ICON_1" API_URL = "https://cloud.eldesalarms.com:8083/api/" diff --git a/custom_components/eldes_alarm/core/eldes_cloud.py b/custom_components/eldes_alarm/core/eldes_cloud.py index dbe5c15..24fa876 100644 --- a/custom_components/eldes_alarm/core/eldes_cloud.py +++ b/custom_components/eldes_alarm/core/eldes_cloud.py @@ -46,6 +46,8 @@ async def _setOAuthHeader(self, data): async def _api_call(self, url, method, data=None): try: + _LOGGER.debug("API Call -> %s %s | Headers: %s | Data: %s", method, url, self.headers, data) + async with async_timeout.timeout(self.timeout): req = await self._http_session.request( method, @@ -53,15 +55,36 @@ async def _api_call(self, url, method, data=None): json=data, headers=self.headers ) + req.raise_for_status() return req + except aiohttp.ClientResponseError as err: + _LOGGER.error("Client response error on API %s request: %s", url, err) + raise + except aiohttp.ClientError as err: - _LOGGER.error("Client error on API %s request %s", url, err) + _LOGGER.error("Client error on API %s request: %s", url, err) raise except asyncio.TimeoutError: - _LOGGER.error("Client timeout error on API request %s", url) + _LOGGER.error("Timeout error on API request: %s", url) + raise + + async def _safe_api_call(self, url, method, data=None): + """Wrapper for API calls with auto token renewal and reconnect on auth errors.""" + try: + return await self._api_call(url, method, data) + + except aiohttp.ClientResponseError as err: + if err.status in (401, 403): + _LOGGER.warning("Auth error (%s) on %s - attempting to re-authenticate.", err.status, url) + await self.renew_token() + try: + return await self._api_call(url, method, data) + except Exception as retry_err: + _LOGGER.error("Retry failed for %s: %s", url, retry_err) + raise raise async def login(self): @@ -72,194 +95,95 @@ async def login(self): } url = f"{API_URL}{API_PATHS['AUTH']}login" - resp = await self._api_call(url, "POST", data) result = await resp.json() - _LOGGER.debug( - "login result: %s", - result - ) - + _LOGGER.debug("login result: %s", result) return await self._setOAuthHeader(result) async def renew_token(self): - """Updates auth token.""" - headers = self.headers - headers['Authorization'] = f"Bearer {self.refresh_token}" - + """Updates auth token, falls back to login on 403.""" + self.headers['Authorization'] = f"Bearer {self.refresh_token}" url = f"{API_URL}{API_PATHS['AUTH']}token" - response = await self._http_session.get( - url, - timeout=self.timeout, - headers=headers - ) - result = await response.json() + try: + async with async_timeout.timeout(self.timeout): + response = await self._http_session.get(url, headers=self.headers) - _LOGGER.debug( - "renew_token result: %s", - result - ) + if response.status == 403: + _LOGGER.warning("Token refresh returned 403 - falling back to full login.") + return await self.login() - return await self._setOAuthHeader(result) + response.raise_for_status() + result = await response.json() - async def get_devices(self): - """Gets device list.""" - url = f"{API_URL}{API_PATHS['DEVICE']}list" + _LOGGER.debug("Token successfully refreshed: %s", result) + return await self._setOAuthHeader(result) - response = await self._api_call(url, "GET") - result = await response.json() - devices = result.get("deviceListEntries", []) + except aiohttp.ClientResponseError as err: + _LOGGER.error("Token refresh failed: %s", err) + raise - _LOGGER.debug( - "get_devices result: %s", - devices - ) + except Exception as e: + _LOGGER.error("Unexpected error during token refresh: %s", e) + raise - return devices + async def get_devices(self): + url = f"{API_URL}{API_PATHS['DEVICE']}list" + response = await self._safe_api_call(url, "GET") + result = await response.json() + return result.get("deviceListEntries", []) async def get_device_info(self, imei): - """Gets device information.""" url = f"{API_URL}{API_PATHS['DEVICE']}info?imei={imei}" - - response = await self._api_call(url, "GET") - result = await response.json() - - _LOGGER.debug( - "get_device_info result: %s", - result - ) - - return result + response = await self._safe_api_call(url, "GET") + return await response.json() async def get_device_partitions(self, imei): - """Gets device partitions/zones.""" - data = { - 'imei': imei - } - + data = {'imei': imei} url = f"{API_URL}{API_PATHS['DEVICE']}partition/list?imei={imei}" - - response = await self._api_call(url, "POST", data) + response = await self._safe_api_call(url, "POST", data) result = await response.json() partitions = result.get("partitions", []) - # Replace Eldes state with HA state name - for partitionIndex, _ in enumerate(partitions): - partitions[partitionIndex]["state"] = ALARM_STATES_MAP[partitions[partitionIndex].get("state", AlarmControlPanelState.DISARMED)] - - _LOGGER.debug( - "get_device_partitions result: %s", - partitions - ) + for partition in partitions: + state = partition.get("state", AlarmControlPanelState.DISARMED) + partition["state"] = ALARM_STATES_MAP.get(state, AlarmControlPanelState.DISARMED) return partitions async def get_device_outputs(self, imei): - """Gets device outputs/automations.""" - data = { - 'imei': imei - } - + data = {'imei': imei} url = f"{API_URL}{API_PATHS['DEVICE']}list-outputs/{imei}" - - response = await self._api_call(url, "POST", data) + response = await self._safe_api_call(url, "POST", data) result = await response.json() - outputs = result.get("deviceOutputs", []) - - _LOGGER.debug( - "get_device_outputs result: %s", - outputs - ) - - return outputs + return result.get("deviceOutputs", []) async def set_alarm(self, mode, imei, zone_id): - """Sets alarm to provided mode.""" - data = { - 'imei': imei, - 'partitionIndex': zone_id - } - + data = {'imei': imei, 'partitionIndex': zone_id} url = f"{API_URL}{API_PATHS['DEVICE']}action/{mode}" - - response = await self._api_call(url, "POST", data) - result = await response.text() - - _LOGGER.debug( - "set_alarm result: %s", - result - ) - - return result + response = await self._safe_api_call(url, "POST", data) + return await response.text() async def turn_on_output(self, imei, output_id): - """Turns on output.""" - data = { - "": "" - } - url = f"{API_URL}{API_PATHS['DEVICE']}control/enable/{imei}/{output_id}" - - response = await self._api_call(url, "PUT", data) - - _LOGGER.debug( - "turn_on_output response: %s", - response - ) - + response = await self._safe_api_call(url, "PUT", {}) return response async def turn_off_output(self, imei, output_id): - """Turns off output.""" - data = { - "": "" - } - url = f"{API_URL}{API_PATHS['DEVICE']}control/disable/{imei}/{output_id}" - - response = await self._api_call(url, "PUT", data) - - _LOGGER.debug( - "turn_off_output response: %s", - response - ) - + response = await self._safe_api_call(url, "PUT", {}) return response async def get_temperatures(self, imei): - """Gets device information.""" url = f"{API_URL}{API_PATHS['DEVICE']}temperatures?imei={imei}" - - response = await self._api_call(url, "POST", {}) + response = await self._safe_api_call(url, "POST", {}) result = await response.json() - temperatures = result.get("temperatureDetailsList", []) - - _LOGGER.debug( - "get_temperatures result: %s", - temperatures - ) - - return temperatures + return result.get("temperatureDetailsList", []) async def get_events(self, size): - """Gets device events.""" - data = { - "": "", - "size": size, - "start": 0 - } - + data = {"": "", "size": size, "start": 0} url = f"{API_URL}{API_PATHS['DEVICE']}event/list" - - response = await self._api_call(url, "POST", data) + response = await self._safe_api_call(url, "POST", data) result = await response.json() - events = result.get("eventDetails", []) - - _LOGGER.debug( - "get_events result: %s", - events - ) - - return events + return result.get("eventDetails", []) diff --git a/custom_components/eldes_alarm/manifest.json b/custom_components/eldes_alarm/manifest.json index b26c09c..edc1a2f 100644 --- a/custom_components/eldes_alarm/manifest.json +++ b/custom_components/eldes_alarm/manifest.json @@ -5,8 +5,8 @@ "config_flow": true, "dependencies": ["http"], "documentation": "https://github.com/augustas2/eldes/blob/master/README.md", - "iot_class": "cloud_polling", "issue_tracker": "https://github.com/augustas2/eldes/issues", - "requirements": [], + "iot_class": "cloud_polling", + "requirements": ["aiohttp>=3.8.0"], "version": "1.0.0" } diff --git a/custom_components/eldes_alarm/sensor.py b/custom_components/eldes_alarm/sensor.py index 0923aa0..4b9cd41 100644 --- a/custom_components/eldes_alarm/sensor.py +++ b/custom_components/eldes_alarm/sensor.py @@ -4,11 +4,9 @@ from homeassistant.components.sensor import SensorEntity, SensorDeviceClass from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.const import ( - PERCENTAGE, - UnitOfTemperature -) +from homeassistant.core import HomeAssistant +from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DATA_CLIENT, @@ -18,9 +16,8 @@ BATTERY_STATUS_MAP, ATTR_EVENTS, ATTR_ALARMS, - ATTR_USER_ACTIONS + ATTR_USER_ACTIONS, ) -from . import EldesDeviceEntity _LOGGER = logging.getLogger(__name__) @@ -36,154 +33,137 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e entities.append(EldesGSMStrengthSensor(client, coordinator, index)) entities.append(EldesPhoneNumberSensor(client, coordinator, index)) entities.append(EventsSensor(client, coordinator, index)) - for tempIndex, _ in enumerate(coordinator.data[index]["temp"]): - entities.append(EldesTemperatureSensor(client, coordinator, index, tempIndex)) + for temp_index, _ in enumerate(coordinator.data[index]["temp"]): + entities.append(EldesTemperatureSensor(client, coordinator, index, temp_index)) async_add_entities(entities) -class EldesBatteryStatusSensor(EldesDeviceEntity, SensorEntity): - """Class for the battery status sensor.""" +class BaseEldesSensor(CoordinatorEntity, SensorEntity): + def __init__(self, client, coordinator, device_index): + super().__init__(coordinator) + self.client = client + self.device_index = device_index + + @property + def imei(self): + return self.coordinator.data[self.device_index].get("imei") + + @property + def data(self): + return self.coordinator.data[self.device_index] + +class EldesBatteryStatusSensor(BaseEldesSensor): @property def unique_id(self): - """Return a unique identifier for this entity.""" return f"{self.imei}_battery_status" @property def name(self): - """Return the name of the sensor.""" return f"{self.data['info']['model']} Battery Status" @property def icon(self): - """Return the icon of this sensor.""" - if not self.data["info"]["batteryStatus"]: - return "mdi:battery-alert-variant-outline" - return "mdi:battery" + return "mdi:battery" if self.data["info"].get("batteryStatus") else "mdi:battery-alert-variant-outline" @property def native_value(self): - """Return the state of the sensor.""" return BATTERY_STATUS_MAP[self.data["info"].get("batteryStatus", False)] -class EldesGSMStrengthSensor(EldesDeviceEntity, SensorEntity): - """Class for the GSM strength sensor.""" - +class EldesGSMStrengthSensor(BaseEldesSensor): @property def unique_id(self): - """Return a unique identifier for this entity.""" return f"{self.imei}_gsm_strength" @property def name(self): - """Return the name of the sensor.""" return f"{self.data['info']['model']} GSM Strength" @property def icon(self): - """Return the icon of this sensor.""" - if self.data["info"]["gsmStrength"] == 0: - return "mdi:signal-off" - return "mdi:signal" + return "mdi:signal" if self.data["info"].get("gsmStrength", 0) > 0 else "mdi:signal-off" @property def native_unit_of_measurement(self): - """Return the unit of measurement.""" return PERCENTAGE @property def native_value(self): - """Return the state of the sensor.""" return SIGNAL_STRENGTH_MAP[self.data["info"].get("gsmStrength", 0)] -class EldesPhoneNumberSensor(EldesDeviceEntity, SensorEntity): - """Class for the phone number sensor.""" - +class EldesPhoneNumberSensor(BaseEldesSensor): @property def unique_id(self): - """Return a unique identifier for this entity.""" return f"{self.imei}_phone_number" @property def name(self): - """Return the name of the sensor.""" return f"{self.data['info']['model']} Phone Number" @property def icon(self): - """Return the icon of this sensor.""" return "mdi:cellphone" @property def native_value(self): - """Return the state of the sensor.""" return self.data["info"].get("phoneNumber", "") -class EldesTemperatureSensor(EldesDeviceEntity, SensorEntity): - """Class for the temperature sensor.""" +class EldesTemperatureSensor(BaseEldesSensor): + def __init__(self, client, coordinator, device_index, entity_index): + super().__init__(client, coordinator, device_index) + self.entity_index = entity_index @property def unique_id(self): - """Return a unique identifier for this entity.""" - return f"{self.imei}_{self.__get_temp()['sensorName']}_{self.__get_temp()['sensorId']}_temperature" + t = self.__get_temp() + return f"{self.imei}_{t['sensorName']}_{t['sensorId']}_temperature" @property def name(self): - """Return the name of the sensor.""" return f"{self.__get_temp()['sensorName']} Temperature" @property def device_class(self): - """Return the device class.""" return SensorDeviceClass.TEMPERATURE @property def native_unit_of_measurement(self): - """Return the unit of measurement.""" return UnitOfTemperature.CELSIUS @property def native_value(self): - """Return the value of the sensor.""" return self.__get_temp().get("temperature", 0.0) def __get_temp(self): - """Return sensor data.""" return self.data["temp"][self.entity_index] -class EventsSensor(EldesDeviceEntity, SensorEntity): - """Class for the events sensor.""" - +class EventsSensor(BaseEldesSensor): @property def unique_id(self): - """Return a unique identifier for this entity.""" return f"{self.imei}_events" @property def name(self): - """Return the name of the sensor.""" - return f"Events" + return "Events" @property def native_value(self): - """Return the value of the sensor.""" - return len(self.data["events"]) + return len(self.data.get("events", [])) @property def extra_state_attributes(self): - """Return the state attributes.""" events = [] alarms = [] user_actions = [] - for event in self.data["events"]: + for event in self.data.get("events", []): if event["type"] == "ALARM": alarms.append(self.__add_time(event)) - elif event["type"] == "ARM" or event["type"] == "DISARM": + elif event["type"] in ("ARM", "DISARM"): user_actions.append(self.__add_time_and_name(event)) else: events.append(self.__add_time(event)) @@ -196,37 +176,25 @@ def extra_state_attributes(self): @property def icon(self): - """Return the icon to use in the frontend.""" return "mdi:calendar" def __add_time_and_name(self, event): - new_event = event - - message = event["message"] - name = message.split(" ")[0] - - additional_fields = { - 'name': name, - } - new_event.update(additional_fields) + new_event = event.copy() + message = new_event.get("message", "") + name = message.split(" ")[0] if message else "" + new_event.update({"name": name}) return self.__add_time(new_event) def __add_time(self, event): - new_event = event - - device_time = new_event["deviceTime"] + new_event = event.copy() + device_time = new_event.get("deviceTime", []) year = self.__safe_list_get(device_time, 0, 2000) month = self.__safe_list_get(device_time, 1, 1) day = self.__safe_list_get(device_time, 2, 1) hour = self.__safe_list_get(device_time, 3, 0) minutes = self.__safe_list_get(device_time, 4, 0) seconds = self.__safe_list_get(device_time, 5, 0) - new_date = datetime(year, month, day, hour, minutes, seconds) - - additional_fields = { - 'event_time': new_date, - } - new_event.update(additional_fields) + new_event["event_time"] = datetime(year, month, day, hour, minutes, seconds) return new_event @staticmethod diff --git a/custom_components/eldes_alarm/switch.py b/custom_components/eldes_alarm/switch.py index ee8565e..9b9d5bb 100644 --- a/custom_components/eldes_alarm/switch.py +++ b/custom_components/eldes_alarm/switch.py @@ -1,98 +1,93 @@ -"""Support for Eldes sensors.""" +"""Support for Eldes switches.""" import logging from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DATA_CLIENT, DATA_COORDINATOR, DOMAIN, - OUTPUT_ICONS_MAP + OUTPUT_ICONS_MAP, + DEFAULT_OUTPUT_ICON, ) -from . import EldesDeviceEntity _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities): - """Set up the Eldes sensor platform.""" + """Set up the Eldes switch platform.""" client = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] entities = [] - for deviceIndex, _ in enumerate(coordinator.data): - for outputIndex, _ in enumerate(coordinator.data[deviceIndex]["outputs"]): - entities.append(EldesSwitch(client, coordinator, deviceIndex, outputIndex)) + for device_index, _ in enumerate(coordinator.data): + for output_index, _ in enumerate(coordinator.data[device_index]["outputs"]): + entities.append(EldesSwitch(client, coordinator, device_index, output_index)) async_add_entities(entities) -class EldesSwitch(EldesDeviceEntity, SwitchEntity): - """Class for the battery status sensor.""" +class EldesSwitch(CoordinatorEntity, SwitchEntity): + """Representation of an Eldes output switch.""" + + def __init__(self, client, coordinator, device_index, entity_index): + super().__init__(coordinator) + self.client = client + self.device_index = device_index + self.entity_index = entity_index + + @property + def imei(self): + return self.coordinator.data[self.device_index].get("imei") + + @property + def data(self): + return self.coordinator.data[self.device_index] @property def unique_id(self): - """Return a unique identifier for this entity.""" - return f"{self.imei}_output_{self.data['outputs'][self.entity_index]['id']}" + return f"{self.imei}_output_{self.output['id']}" @property def name(self): - """Return the name of the sensor.""" - return self.data['outputs'][self.entity_index]['name'] + return self.output['name'] @property def is_on(self): - """Return true if switch is on.""" - return self.data["outputs"][self.entity_index].get("outputState", False) + return self.output.get("outputState", False) @property def extra_state_attributes(self): - """Return the state attributes.""" - output = self.data["outputs"][self.entity_index] - return { - "hasFault": output["hasFault"], - "outputState": output["outputState"], - "type": output["type"] + "hasFault": self.output.get("hasFault"), + "outputState": self.output.get("outputState"), + "type": self.output.get("type"), } @property def icon(self): - """Return the icon of this sensor.""" try: - iconName = self.data["outputs"][self.entity_index]["iconName"] - - if iconName is not None: - return OUTPUT_ICONS_MAP[iconName] - - return OUTPUT_ICONS_MAP["ICON_1"] - + icon_name = self.output.get("iconName") + if icon_name is not None: + return OUTPUT_ICONS_MAP.get(icon_name, OUTPUT_ICONS_MAP[DEFAULT_OUTPUT_ICON]) except Exception: - _LOGGER.info("Unknown output icon for (%s)", self.data['outputs'][self.entity_index]['name']) - return OUTPUT_ICONS_MAP["ICON_1"] + _LOGGER.info("Unknown output icon for (%s)", self.output.get("name")) - async def async_turn_on(self): - """Turn the entity on.""" - output = self.data["outputs"][self.entity_index] + return OUTPUT_ICONS_MAP[DEFAULT_OUTPUT_ICON] - await self.client.turn_on_output( - self.imei, - output['id'] - ) + @property + def output(self): + return self.data["outputs"][self.entity_index] - self.data["outputs"][self.entity_index]["outputState"] = True + async def async_turn_on(self): + await self.client.turn_on_output(self.imei, self.output['id']) + self.output["outputState"] = True self.async_write_ha_state() async def async_turn_off(self): - """Turn the entity off.""" - output = self.data["outputs"][self.entity_index] - - await self.client.turn_off_output( - self.imei, - output['id'] - ) - - self.data["outputs"][self.entity_index]["outputState"] = False + await self.client.turn_off_output(self.imei, self.output['id']) + self.output["outputState"] = False self.async_write_ha_state() From 69002d53a8097cc560d45cd39dc48ef20d54d9a5 Mon Sep 17 00:00:00 2001 From: Augustas Cirba Date: Fri, 18 Apr 2025 22:41:27 +0300 Subject: [PATCH 03/10] More updates --- custom_components/eldes_alarm/__init__.py | 28 -------- .../eldes_alarm/alarm_control_panel.py | 70 ++++++------------- .../eldes_alarm/binary_sensor.py | 20 ++---- .../eldes_alarm/core/eldes_cloud.py | 66 ++++++++--------- custom_components/eldes_alarm/sensor.py | 54 ++++++-------- custom_components/eldes_alarm/switch.py | 53 ++++++-------- 6 files changed, 104 insertions(+), 187 deletions(-) diff --git a/custom_components/eldes_alarm/__init__.py b/custom_components/eldes_alarm/__init__.py index 55a9f74..6b816e4 100644 --- a/custom_components/eldes_alarm/__init__.py +++ b/custom_components/eldes_alarm/__init__.py @@ -151,31 +151,3 @@ def device_info(self): "sw_version": self.data["info"]["firmware"], "model": self.data["info"]["model"] } - - -class EldesZoneEntity(CoordinatorEntity): - """Defines a base Eldes zone entity.""" - - def __init__(self, client, coordinator, device_index, entity_index): - """Initialize the Eldes entity.""" - super().__init__(coordinator) - self.client = client - self.device_index = device_index - self.entity_index = entity_index - self.imei = self.coordinator.data[self.device_index]["imei"] - - @property - def data(self): - """Shortcut to access data for the entity.""" - return self.coordinator.data[self.device_index]["partitions"][self.entity_index] - - @property - def device_info(self): - """Return zone info for the Eldes entity.""" - return { - "identifiers": {(DOMAIN, self.data["internalId"])}, - "name": self.data["name"], - "manufacturer": DEFAULT_NAME, - "model": DEFAULT_ZONE, - "suggested_area": self.data["name"] - } diff --git a/custom_components/eldes_alarm/alarm_control_panel.py b/custom_components/eldes_alarm/alarm_control_panel.py index 3d24b3c..e00d4dd 100644 --- a/custom_components/eldes_alarm/alarm_control_panel.py +++ b/custom_components/eldes_alarm/alarm_control_panel.py @@ -1,4 +1,4 @@ -"""Interfaces with Eldes control panels.""" +"""Support for Eldes control panels.""" import logging from homeassistant.components.alarm_control_panel import ( @@ -16,6 +16,7 @@ DOMAIN, ALARM_MODES, ) +from . import EldesDeviceEntity _LOGGER = logging.getLogger(__name__) @@ -33,8 +34,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e async_add_entities(entities) -class EldesAlarmPanel(CoordinatorEntity, AlarmControlPanelEntity): - """Representation of an Eldes Alarm.""" +class EldesAlarmPanel(EldesDeviceEntity, AlarmControlPanelEntity): + """Class for the Eldes alarm control panel.""" _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_AWAY @@ -42,88 +43,63 @@ class EldesAlarmPanel(CoordinatorEntity, AlarmControlPanelEntity): ) _attr_code_arm_required = False - def __init__(self, client, coordinator, device_index, partition_index): - super().__init__(coordinator) - self.client = client - self.device_index = device_index - self.partition_index = partition_index - - @property - def imei(self): - return self.coordinator.data[self.device_index].get("imei") - @property - def data(self): - return self.coordinator.data[self.device_index]["partitions"][self.partition_index] + def partition(self): + return self.coordinator.data[self.device_index]["partitions"][self.entity_index] @property def unique_id(self): - return f"{self.imei}_zone_{self.data['internalId']}" + return f"{self.imei}_zone_{self.partition["internalId"]}" @property def name(self): - return self.data["name"] + return self.partition["name"] @property - def state(self): - return self.data["state"] + def alarm_state(self) -> AlarmControlPanelState: + return self.partition["state"] @property def extra_state_attributes(self): return { - "armed": self.data["armed"], - "armStay": self.data["armStay"], - "state": self.data["state"], - "hasUnacceptedPartitionAlarms": self.data["hasUnacceptedPartitionAlarms"], + "armed": self.partition["armed"], + "armStay": self.partition["armStay"], + "state": self.partition["state"], + "hasUnacceptedPartitionAlarms": self.partition["hasUnacceptedPartitionAlarms"], } async def async_alarm_disarm(self, code: str | None = None) -> None: - current_state = self.state - self.data["state"] = AlarmControlPanelState.DISARMING + self._attr_alarm_state = AlarmControlPanelState.DISARMING self.async_write_ha_state() try: await self.client.renew_token() await self.client.set_alarm( - ALARM_MODES["DISARM"], - self.imei, - self.data['internalId'] + ALARM_MODES["DISARM"], self.imei, self.partition["internalId"] ) except Exception as ex: - _LOGGER.error("Failed to change state: %s", ex) - self.data["state"] = current_state - self.async_write_ha_state() + _LOGGER.error("Failed to disarm: %s", ex) async def async_alarm_arm_away(self, code: str | None = None) -> None: - current_state = self.state - self.data["state"] = AlarmControlPanelState.ARMING + self._attr_alarm_state = AlarmControlPanelState.ARMING self.async_write_ha_state() try: await self.client.renew_token() await self.client.set_alarm( - ALARM_MODES["ARM_AWAY"], - self.imei, - self.data['internalId'] + ALARM_MODES["ARM_AWAY"], self.imei, self.partition["internalId"] ) except Exception as ex: - _LOGGER.error("Failed to change state: %s", ex) - self.data["state"] = current_state - self.async_write_ha_state() + _LOGGER.error("Failed to arm away: %s", ex) async def async_alarm_arm_home(self, code: str | None = None) -> None: - current_state = self.state - self.data["state"] = AlarmControlPanelState.ARMING + self._attr_alarm_state = AlarmControlPanelState.ARMING self.async_write_ha_state() try: await self.client.renew_token() await self.client.set_alarm( - ALARM_MODES["ARM_HOME"], - self.imei, - self.data['internalId'] + ALARM_MODES["ARM_HOME"], self.imei, self.partition["internalId"] ) except Exception as ex: - _LOGGER.error("Failed to change state: %s", ex) - self.data["state"] = current_state - self.async_write_ha_state() + _LOGGER.error("Failed to arm home: %s", ex) diff --git a/custom_components/eldes_alarm/binary_sensor.py b/custom_components/eldes_alarm/binary_sensor.py index 0fec601..7b564bc 100644 --- a/custom_components/eldes_alarm/binary_sensor.py +++ b/custom_components/eldes_alarm/binary_sensor.py @@ -1,4 +1,4 @@ -"""Support for Eldes sensors.""" +"""Support for Eldes binary sensors.""" import logging from homeassistant.components.binary_sensor import ( @@ -14,6 +14,7 @@ DATA_COORDINATOR, DOMAIN, ) +from . import EldesDeviceEntity _LOGGER = logging.getLogger(__name__) @@ -30,29 +31,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e async_add_entities(entities) -class EldesConnectionStatusBinarySensor(CoordinatorEntity, BinarySensorEntity): +class EldesConnectionStatusBinarySensor(EldesDeviceEntity, BinarySensorEntity): """Class for the Eldes connection status sensor.""" - def __init__(self, client, coordinator, device_index): - super().__init__(coordinator) - self.client = client - self.device_index = device_index - - @property - def imei(self): - return self.coordinator.data[self.device_index].get("imei") - - @property - def data(self): - return self.coordinator.data[self.device_index] - @property def unique_id(self): return f"{self.imei}_connection_status" @property def name(self): - return f"{self.data['info']['model']} Connection Status" + return f"{self.data["info"]["model"]} Connection Status" @property def is_on(self): diff --git a/custom_components/eldes_alarm/core/eldes_cloud.py b/custom_components/eldes_alarm/core/eldes_cloud.py index 24fa876..5c2afb9 100644 --- a/custom_components/eldes_alarm/core/eldes_cloud.py +++ b/custom_components/eldes_alarm/core/eldes_cloud.py @@ -3,6 +3,7 @@ import async_timeout import logging import aiohttp +from datetime import datetime, timedelta from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState @@ -23,24 +24,25 @@ class EldesCloud: """Interacts with Eldes via public API.""" def __init__(self, session: aiohttp.ClientSession, username: str, password: str): - """Performs login and save session cookie.""" self.timeout = 30 self.headers = { - 'X-Requested-With': 'XMLHttpRequest', - 'x-whitelable': 'eldes' + "X-Requested-With": "XMLHttpRequest", + "x-whitelable": "eldes" } - self.refresh_token = '' + self.refresh_token = "" + self.token_expires_at = None self._http_session = session self._username = username self._password = password async def _setOAuthHeader(self, data): - if 'refreshToken' in data: - self.refresh_token = data['refreshToken'] + if "refreshToken" in data: + self.refresh_token = data["refreshToken"] - if 'token' in data: - self.headers['Authorization'] = f"Bearer {data['token']}" + if "token" in data: + self.headers["Authorization"] = f"Bearer {data["token"]}" + self.token_expires_at = datetime.utcnow() + timedelta(minutes=4) # token lasts 5 minutes, refresh 1 minute before return data @@ -72,14 +74,13 @@ async def _api_call(self, url, method, data=None): raise async def _safe_api_call(self, url, method, data=None): - """Wrapper for API calls with auto token renewal and reconnect on auth errors.""" try: return await self._api_call(url, method, data) except aiohttp.ClientResponseError as err: if err.status in (401, 403): _LOGGER.warning("Auth error (%s) on %s - attempting to re-authenticate.", err.status, url) - await self.renew_token() + await self.login() try: return await self._api_call(url, method, data) except Exception as retry_err: @@ -89,12 +90,12 @@ async def _safe_api_call(self, url, method, data=None): async def login(self): data = { - 'email': self._username, - 'password': self._password, - 'hostDeviceId': '' + "email": self._username, + "password": self._password, + "hostDeviceId": "" } - url = f"{API_URL}{API_PATHS['AUTH']}login" + url = f"{API_URL}{API_PATHS["AUTH"]}login" resp = await self._api_call(url, "POST", data) result = await resp.json() @@ -102,18 +103,17 @@ async def login(self): return await self._setOAuthHeader(result) async def renew_token(self): - """Updates auth token, falls back to login on 403.""" - self.headers['Authorization'] = f"Bearer {self.refresh_token}" - url = f"{API_URL}{API_PATHS['AUTH']}token" + if not self.token_expires_at or datetime.utcnow() < self.token_expires_at: + _LOGGER.debug("Token is still valid; skipping token refresh.") + return + + self.headers["Authorization"] = f"Bearer {self.refresh_token}" + url = f"{API_URL}{API_PATHS["AUTH"]}token" try: async with async_timeout.timeout(self.timeout): response = await self._http_session.get(url, headers=self.headers) - if response.status == 403: - _LOGGER.warning("Token refresh returned 403 - falling back to full login.") - return await self.login() - response.raise_for_status() result = await response.json() @@ -129,19 +129,19 @@ async def renew_token(self): raise async def get_devices(self): - url = f"{API_URL}{API_PATHS['DEVICE']}list" + url = f"{API_URL}{API_PATHS["DEVICE"]}list" response = await self._safe_api_call(url, "GET") result = await response.json() return result.get("deviceListEntries", []) async def get_device_info(self, imei): - url = f"{API_URL}{API_PATHS['DEVICE']}info?imei={imei}" + url = f"{API_URL}{API_PATHS["DEVICE"]}info?imei={imei}" response = await self._safe_api_call(url, "GET") return await response.json() async def get_device_partitions(self, imei): - data = {'imei': imei} - url = f"{API_URL}{API_PATHS['DEVICE']}partition/list?imei={imei}" + data = {"imei": imei} + url = f"{API_URL}{API_PATHS["DEVICE"]}partition/list?imei={imei}" response = await self._safe_api_call(url, "POST", data) result = await response.json() partitions = result.get("partitions", []) @@ -153,37 +153,37 @@ async def get_device_partitions(self, imei): return partitions async def get_device_outputs(self, imei): - data = {'imei': imei} - url = f"{API_URL}{API_PATHS['DEVICE']}list-outputs/{imei}" + data = {"imei": imei} + url = f"{API_URL}{API_PATHS["DEVICE"]}list-outputs/{imei}" response = await self._safe_api_call(url, "POST", data) result = await response.json() return result.get("deviceOutputs", []) async def set_alarm(self, mode, imei, zone_id): - data = {'imei': imei, 'partitionIndex': zone_id} - url = f"{API_URL}{API_PATHS['DEVICE']}action/{mode}" + data = {"imei": imei, "partitionIndex": zone_id} + url = f"{API_URL}{API_PATHS["DEVICE"]}action/{mode}" response = await self._safe_api_call(url, "POST", data) return await response.text() async def turn_on_output(self, imei, output_id): - url = f"{API_URL}{API_PATHS['DEVICE']}control/enable/{imei}/{output_id}" + url = f"{API_URL}{API_PATHS["DEVICE"]}control/enable/{imei}/{output_id}" response = await self._safe_api_call(url, "PUT", {}) return response async def turn_off_output(self, imei, output_id): - url = f"{API_URL}{API_PATHS['DEVICE']}control/disable/{imei}/{output_id}" + url = f"{API_URL}{API_PATHS["DEVICE"]}control/disable/{imei}/{output_id}" response = await self._safe_api_call(url, "PUT", {}) return response async def get_temperatures(self, imei): - url = f"{API_URL}{API_PATHS['DEVICE']}temperatures?imei={imei}" + url = f"{API_URL}{API_PATHS["DEVICE"]}temperatures?imei={imei}" response = await self._safe_api_call(url, "POST", {}) result = await response.json() return result.get("temperatureDetailsList", []) async def get_events(self, size): data = {"": "", "size": size, "start": 0} - url = f"{API_URL}{API_PATHS['DEVICE']}event/list" + url = f"{API_URL}{API_PATHS["DEVICE"]}event/list" response = await self._safe_api_call(url, "POST", data) result = await response.json() return result.get("eventDetails", []) diff --git a/custom_components/eldes_alarm/sensor.py b/custom_components/eldes_alarm/sensor.py index 4b9cd41..ff3d3a5 100644 --- a/custom_components/eldes_alarm/sensor.py +++ b/custom_components/eldes_alarm/sensor.py @@ -18,6 +18,7 @@ ATTR_ALARMS, ATTR_USER_ACTIONS, ) +from . import EldesDeviceEntity _LOGGER = logging.getLogger(__name__) @@ -39,29 +40,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e async_add_entities(entities) -class BaseEldesSensor(CoordinatorEntity, SensorEntity): - def __init__(self, client, coordinator, device_index): - super().__init__(coordinator) - self.client = client - self.device_index = device_index +class EldesBatteryStatusSensor(EldesDeviceEntity, SensorEntity): + """Class for the battery status sensor.""" - @property - def imei(self): - return self.coordinator.data[self.device_index].get("imei") - - @property - def data(self): - return self.coordinator.data[self.device_index] - - -class EldesBatteryStatusSensor(BaseEldesSensor): @property def unique_id(self): return f"{self.imei}_battery_status" @property def name(self): - return f"{self.data['info']['model']} Battery Status" + return f"{self.data["info"]["model"]} Battery Status" @property def icon(self): @@ -72,14 +60,16 @@ def native_value(self): return BATTERY_STATUS_MAP[self.data["info"].get("batteryStatus", False)] -class EldesGSMStrengthSensor(BaseEldesSensor): +class EldesGSMStrengthSensor(EldesDeviceEntity, SensorEntity): + """Class for the GSM strength sensor.""" + @property def unique_id(self): return f"{self.imei}_gsm_strength" @property def name(self): - return f"{self.data['info']['model']} GSM Strength" + return f"{self.data["info"]["model"]} GSM Strength" @property def icon(self): @@ -94,14 +84,16 @@ def native_value(self): return SIGNAL_STRENGTH_MAP[self.data["info"].get("gsmStrength", 0)] -class EldesPhoneNumberSensor(BaseEldesSensor): +class EldesPhoneNumberSensor(EldesDeviceEntity, SensorEntity): + """Class for the phone number sensor.""" + @property def unique_id(self): return f"{self.imei}_phone_number" @property def name(self): - return f"{self.data['info']['model']} Phone Number" + return f"{self.data["info"]["model"]} Phone Number" @property def icon(self): @@ -112,19 +104,20 @@ def native_value(self): return self.data["info"].get("phoneNumber", "") -class EldesTemperatureSensor(BaseEldesSensor): - def __init__(self, client, coordinator, device_index, entity_index): - super().__init__(client, coordinator, device_index) - self.entity_index = entity_index +class EldesTemperatureSensor(EldesDeviceEntity, SensorEntity): + """Class for the temperature sensor.""" + + @property + def temp(self): + return self.data["temp"][self.entity_index] @property def unique_id(self): - t = self.__get_temp() - return f"{self.imei}_{t['sensorName']}_{t['sensorId']}_temperature" + return f"{self.imei}_{self.temp["sensorName"]}_{self.temp["sensorId"]}_temperature" @property def name(self): - return f"{self.__get_temp()['sensorName']} Temperature" + return f"{self.temp["sensorName"]} Temperature" @property def device_class(self): @@ -136,13 +129,12 @@ def native_unit_of_measurement(self): @property def native_value(self): - return self.__get_temp().get("temperature", 0.0) + return self.temp.get("temperature", 0.0) - def __get_temp(self): - return self.data["temp"][self.entity_index] +class EventsSensor(EldesDeviceEntity, SensorEntity): + """Class for the events sensor.""" -class EventsSensor(BaseEldesSensor): @property def unique_id(self): return f"{self.imei}_events" diff --git a/custom_components/eldes_alarm/switch.py b/custom_components/eldes_alarm/switch.py index 9b9d5bb..41d3e81 100644 --- a/custom_components/eldes_alarm/switch.py +++ b/custom_components/eldes_alarm/switch.py @@ -13,6 +13,7 @@ OUTPUT_ICONS_MAP, DEFAULT_OUTPUT_ICON, ) +from . import EldesDeviceEntity _LOGGER = logging.getLogger(__name__) @@ -30,30 +31,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e async_add_entities(entities) -class EldesSwitch(CoordinatorEntity, SwitchEntity): +class EldesSwitch(EldesDeviceEntity, SwitchEntity): """Representation of an Eldes output switch.""" - def __init__(self, client, coordinator, device_index, entity_index): - super().__init__(coordinator) - self.client = client - self.device_index = device_index - self.entity_index = entity_index - - @property - def imei(self): - return self.coordinator.data[self.device_index].get("imei") - @property - def data(self): - return self.coordinator.data[self.device_index] + def output(self): + return self.data["outputs"][self.entity_index] @property def unique_id(self): - return f"{self.imei}_output_{self.output['id']}" + return f"{self.imei}_output_{self.output["id"]}" @property def name(self): - return self.output['name'] + return self.output["name"] @property def is_on(self): @@ -62,32 +53,30 @@ def is_on(self): @property def extra_state_attributes(self): return { - "hasFault": self.output.get("hasFault"), - "outputState": self.output.get("outputState"), - "type": self.output.get("type"), + "hasFault": self.output["hasFault"], + "outputState": self.output["outputState"], + "type": self.output["type"] } @property def icon(self): - try: - icon_name = self.output.get("iconName") - if icon_name is not None: - return OUTPUT_ICONS_MAP.get(icon_name, OUTPUT_ICONS_MAP[DEFAULT_OUTPUT_ICON]) - except Exception: - _LOGGER.info("Unknown output icon for (%s)", self.output.get("name")) - - return OUTPUT_ICONS_MAP[DEFAULT_OUTPUT_ICON] - - @property - def output(self): - return self.data["outputs"][self.entity_index] + icon_name = self.output.get("iconName", DEFAULT_OUTPUT_ICON) + return OUTPUT_ICONS_MAP.get(icon_name, OUTPUT_ICONS_MAP[DEFAULT_OUTPUT_ICON]) async def async_turn_on(self): - await self.client.turn_on_output(self.imei, self.output['id']) + await self.client.turn_on_output( + self.imei, + self.output["id"] + ) + self.output["outputState"] = True self.async_write_ha_state() async def async_turn_off(self): - await self.client.turn_off_output(self.imei, self.output['id']) + await self.client.turn_off_output( + self.imei, + self.output["id"] + ) + self.output["outputState"] = False self.async_write_ha_state() From 075ec2a4a66b1f6562794b09c5c8b80c287d6f46 Mon Sep 17 00:00:00 2001 From: Augustas Cirba Date: Mon, 21 Apr 2025 20:12:30 +0300 Subject: [PATCH 04/10] More fixes --- custom_components/eldes_alarm/__init__.py | 1 - .../eldes_alarm/alarm_control_panel.py | 18 ++++++++++++------ custom_components/eldes_alarm/const.py | 1 - custom_components/eldes_alarm/manifest.json | 2 +- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/custom_components/eldes_alarm/__init__.py b/custom_components/eldes_alarm/__init__.py index 6b816e4..1d19ee4 100644 --- a/custom_components/eldes_alarm/__init__.py +++ b/custom_components/eldes_alarm/__init__.py @@ -21,7 +21,6 @@ from .const import ( DEFAULT_NAME, - DEFAULT_ZONE, DATA_CLIENT, DATA_COORDINATOR, DATA_DEVICES, diff --git a/custom_components/eldes_alarm/alarm_control_panel.py b/custom_components/eldes_alarm/alarm_control_panel.py index e00d4dd..5cd2ba9 100644 --- a/custom_components/eldes_alarm/alarm_control_panel.py +++ b/custom_components/eldes_alarm/alarm_control_panel.py @@ -29,7 +29,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e for device_index, device in enumerate(coordinator.data): for partition_index, _ in enumerate(device["partitions"]): - entities.append(EldesAlarmPanel(client, coordinator, device_index, partition_index)) + entity = EldesAlarmPanel(client, coordinator, device_index, partition_index) + entity._attr_alarm_state = entity.partition["state"] + entities.append(entity) async_add_entities(entities) @@ -45,7 +47,7 @@ class EldesAlarmPanel(EldesDeviceEntity, AlarmControlPanelEntity): @property def partition(self): - return self.coordinator.data[self.device_index]["partitions"][self.entity_index] + return self.data["partitions"][self.entity_index] @property def unique_id(self): @@ -55,10 +57,6 @@ def unique_id(self): def name(self): return self.partition["name"] - @property - def alarm_state(self) -> AlarmControlPanelState: - return self.partition["state"] - @property def extra_state_attributes(self): return { @@ -68,6 +66,11 @@ def extra_state_attributes(self): "hasUnacceptedPartitionAlarms": self.partition["hasUnacceptedPartitionAlarms"], } + def _handle_coordinator_update(self) -> None: + """Update the state when coordinator provides new data.""" + self._attr_alarm_state = self.partition["state"] + self.async_write_ha_state() + async def async_alarm_disarm(self, code: str | None = None) -> None: self._attr_alarm_state = AlarmControlPanelState.DISARMING self.async_write_ha_state() @@ -77,6 +80,7 @@ async def async_alarm_disarm(self, code: str | None = None) -> None: await self.client.set_alarm( ALARM_MODES["DISARM"], self.imei, self.partition["internalId"] ) + await self.coordinator.async_request_refresh() except Exception as ex: _LOGGER.error("Failed to disarm: %s", ex) @@ -89,6 +93,7 @@ async def async_alarm_arm_away(self, code: str | None = None) -> None: await self.client.set_alarm( ALARM_MODES["ARM_AWAY"], self.imei, self.partition["internalId"] ) + await self.coordinator.async_request_refresh() except Exception as ex: _LOGGER.error("Failed to arm away: %s", ex) @@ -101,5 +106,6 @@ async def async_alarm_arm_home(self, code: str | None = None) -> None: await self.client.set_alarm( ALARM_MODES["ARM_HOME"], self.imei, self.partition["internalId"] ) + await self.coordinator.async_request_refresh() except Exception as ex: _LOGGER.error("Failed to arm home: %s", ex) diff --git a/custom_components/eldes_alarm/const.py b/custom_components/eldes_alarm/const.py index 2dca768..9074239 100644 --- a/custom_components/eldes_alarm/const.py +++ b/custom_components/eldes_alarm/const.py @@ -2,7 +2,6 @@ DOMAIN = "eldes_alarm" DEFAULT_NAME = "Eldes" -DEFAULT_ZONE = "Zone" DATA_CLIENT = "eldes_client" DATA_COORDINATOR = "coordinator" diff --git a/custom_components/eldes_alarm/manifest.json b/custom_components/eldes_alarm/manifest.json index edc1a2f..d4037fc 100644 --- a/custom_components/eldes_alarm/manifest.json +++ b/custom_components/eldes_alarm/manifest.json @@ -8,5 +8,5 @@ "issue_tracker": "https://github.com/augustas2/eldes/issues", "iot_class": "cloud_polling", "requirements": ["aiohttp>=3.8.0"], - "version": "1.0.0" + "version": "2.0.0" } From 709d7c6d09dd6a4810ba312e7b5e826d92328d6e Mon Sep 17 00:00:00 2001 From: Augustas Cirba Date: Tue, 22 Apr 2025 22:00:40 +0300 Subject: [PATCH 05/10] Added device selection during config flow --- custom_components/eldes_alarm/__init__.py | 73 +++++-------- .../eldes_alarm/alarm_control_panel.py | 62 ++++++----- .../eldes_alarm/binary_sensor.py | 4 +- custom_components/eldes_alarm/config_flow.py | 103 +++++++++++------- custom_components/eldes_alarm/const.py | 2 +- .../eldes_alarm/core/eldes_cloud.py | 24 ++-- custom_components/eldes_alarm/sensor.py | 14 +-- custom_components/eldes_alarm/switch.py | 6 +- .../eldes_alarm/translations/en.json | 18 ++- .../eldes_alarm/translations/lt.json | 18 ++- 10 files changed, 172 insertions(+), 152 deletions(-) diff --git a/custom_components/eldes_alarm/__init__.py b/custom_components/eldes_alarm/__init__.py index 1d19ee4..46f38bf 100644 --- a/custom_components/eldes_alarm/__init__.py +++ b/custom_components/eldes_alarm/__init__.py @@ -4,12 +4,11 @@ import asyncio from http import HTTPStatus -import aiohttp -from aiohttp import ClientError, ClientResponseError +from aiohttp import ClientResponseError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_SCAN_INTERVAL -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, ConfigEntryAuthFailed from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -23,12 +22,13 @@ DEFAULT_NAME, DATA_CLIENT, DATA_COORDINATOR, - DATA_DEVICES, DEFAULT_SCAN_INTERVAL, - DEFAULT_EVENTS_LIST_SIZE, + CONF_DEVICE_IMEI, CONF_EVENTS_LIST_SIZE, - DOMAIN + DEFAULT_EVENTS_LIST_SIZE, + DOMAIN, ) + from .core.eldes_cloud import EldesCloud _LOGGER = logging.getLogger(__name__) @@ -42,6 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Eldes from a config entry.""" username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] + selected_imei = entry.data[CONF_DEVICE_IMEI] scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) session = async_get_clientsession(hass) @@ -54,73 +55,59 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryAuthFailed from ex raise ConfigEntryNotReady from ex except Exception as ex: - _LOGGER.error("Failed to setup Eldes: %s", ex) + _LOGGER.error("Failed to login to Eldes: %s", ex) return False async def async_update_data(): + """Fetch data for selected Eldes device.""" try: - return await async_get_devices(hass, entry, eldes_client) - except ClientResponseError as ex: - if ex.status == HTTPStatus.UNAUTHORIZED: - _LOGGER.warning("Token expired or invalid. Attempting full re-login.") - try: - await eldes_client.login() - return await async_get_devices(hass, entry, eldes_client, skip_token_renew=True) - except Exception as retry_ex: - _LOGGER.exception("Failed to recover after re-login: %s", retry_ex) - raise UpdateFailed(retry_ex) from retry_ex - raise UpdateFailed(ex) from ex + await eldes_client.renew_token() + return [await async_fetch_device_data(eldes_client, selected_imei, entry)] except Exception as ex: - _LOGGER.exception("Unknown error occurred during Eldes update request: %s", ex) + _LOGGER.exception("Failed to update Eldes device data: %s", ex) raise UpdateFailed(ex) from ex coordinator = DataUpdateCoordinator( hass, _LOGGER, - name=DOMAIN, + name=f"Eldes {selected_imei}", update_method=async_update_data, update_interval=timedelta(seconds=scan_interval), ) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { DATA_CLIENT: eldes_client, DATA_COORDINATOR: coordinator, - DATA_DEVICES: [], } - await coordinator.async_config_entry_first_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True -async def async_get_devices(hass: HomeAssistant, entry: ConfigEntry, eldes_client: EldesCloud, skip_token_renew: bool = False): - """Fetch data from Eldes API.""" +async def async_fetch_device_data(eldes_client: EldesCloud, imei: str, entry: ConfigEntry) -> dict: + """Fetch full data for a single Eldes device.""" events_list_size = entry.options.get(CONF_EVENTS_LIST_SIZE, DEFAULT_EVENTS_LIST_SIZE) - if not skip_token_renew: - await eldes_client.renew_token() - - devices = await eldes_client.get_devices() - - for device in devices: - device["info"] = await eldes_client.get_device_info(device["imei"]) - device["partitions"] = await eldes_client.get_device_partitions(device["imei"]) - device["outputs"] = await eldes_client.get_device_outputs(device["imei"]) - device["temp"] = await eldes_client.get_temperatures(device["imei"]) - device["events"] = await eldes_client.get_events(events_list_size) + device = { + "imei": imei, + "info": await eldes_client.get_device_info(imei), + "partitions": await eldes_client.get_device_partitions(imei), + "outputs": await eldes_client.get_device_outputs(imei), + "temp": await eldes_client.get_temperatures(imei), + "events": await eldes_client.get_events(events_list_size), + } - hass.data[DOMAIN][entry.entry_id][DATA_DEVICES] = devices - return devices + return device async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): - """Unload a config entry.""" + """Unload Eldes config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - + hass.data[DOMAIN].pop(entry.entry_id, None) return unload_ok @@ -137,7 +124,7 @@ def __init__(self, client, coordinator, device_index, entity_index=None): @property def data(self): - """Shortcut to access data for the entity.""" + """Shortcut to access this device's data.""" return self.coordinator.data[self.device_index] @property @@ -148,5 +135,5 @@ def device_info(self): "name": self.data["info"]["model"], "manufacturer": DEFAULT_NAME, "sw_version": self.data["info"]["firmware"], - "model": self.data["info"]["model"] + "model": self.data["info"]["model"], } diff --git a/custom_components/eldes_alarm/alarm_control_panel.py b/custom_components/eldes_alarm/alarm_control_panel.py index 5cd2ba9..a029103 100644 --- a/custom_components/eldes_alarm/alarm_control_panel.py +++ b/custom_components/eldes_alarm/alarm_control_panel.py @@ -28,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e entities = [] for device_index, device in enumerate(coordinator.data): - for partition_index, _ in enumerate(device["partitions"]): + for partition_index in range(len(device["partitions"])): entity = EldesAlarmPanel(client, coordinator, device_index, partition_index) entity._attr_alarm_state = entity.partition["state"] entities.append(entity) @@ -45,13 +45,17 @@ class EldesAlarmPanel(EldesDeviceEntity, AlarmControlPanelEntity): ) _attr_code_arm_required = False + def __init__(self, client, coordinator, device_index, partition_index): + super().__init__(client, coordinator, device_index, partition_index) + self._attr_alarm_state = None + @property def partition(self): return self.data["partitions"][self.entity_index] @property def unique_id(self): - return f"{self.imei}_zone_{self.partition["internalId"]}" + return f"{self.imei}_zone_{self.partition['internalId']}" @property def name(self): @@ -71,41 +75,39 @@ def _handle_coordinator_update(self) -> None: self._attr_alarm_state = self.partition["state"] self.async_write_ha_state() - async def async_alarm_disarm(self, code: str | None = None) -> None: - self._attr_alarm_state = AlarmControlPanelState.DISARMING + async def _async_set_alarm(self, mode: str, target_state: AlarmControlPanelState, transition_state: AlarmControlPanelState) -> None: + previous_state = self._attr_alarm_state + self._attr_alarm_state = transition_state self.async_write_ha_state() try: await self.client.renew_token() - await self.client.set_alarm( - ALARM_MODES["DISARM"], self.imei, self.partition["internalId"] - ) + await self.client.set_alarm(mode, self.imei, self.partition["internalId"]) + self._attr_alarm_state = target_state + self.async_write_ha_state() await self.coordinator.async_request_refresh() except Exception as ex: - _LOGGER.error("Failed to disarm: %s", ex) + _LOGGER.error("Failed to set alarm (%s): %s", mode, ex) + self._attr_alarm_state = previous_state + self.async_write_ha_state() - async def async_alarm_arm_away(self, code: str | None = None) -> None: - self._attr_alarm_state = AlarmControlPanelState.ARMING - self.async_write_ha_state() + async def async_alarm_disarm(self, code: str | None = None) -> None: + await self._async_set_alarm( + ALARM_MODES["DISARM"], + AlarmControlPanelState.DISARMED, + AlarmControlPanelState.DISARMING + ) - try: - await self.client.renew_token() - await self.client.set_alarm( - ALARM_MODES["ARM_AWAY"], self.imei, self.partition["internalId"] - ) - await self.coordinator.async_request_refresh() - except Exception as ex: - _LOGGER.error("Failed to arm away: %s", ex) + async def async_alarm_arm_away(self, code: str | None = None) -> None: + await self._async_set_alarm( + ALARM_MODES["ARM_AWAY"], + AlarmControlPanelState.ARMED_AWAY, + AlarmControlPanelState.ARMING + ) async def async_alarm_arm_home(self, code: str | None = None) -> None: - self._attr_alarm_state = AlarmControlPanelState.ARMING - self.async_write_ha_state() - - try: - await self.client.renew_token() - await self.client.set_alarm( - ALARM_MODES["ARM_HOME"], self.imei, self.partition["internalId"] - ) - await self.coordinator.async_request_refresh() - except Exception as ex: - _LOGGER.error("Failed to arm home: %s", ex) + await self._async_set_alarm( + ALARM_MODES["ARM_HOME"], + AlarmControlPanelState.ARMED_HOME, + AlarmControlPanelState.ARMING + ) diff --git a/custom_components/eldes_alarm/binary_sensor.py b/custom_components/eldes_alarm/binary_sensor.py index 7b564bc..0b90647 100644 --- a/custom_components/eldes_alarm/binary_sensor.py +++ b/custom_components/eldes_alarm/binary_sensor.py @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] entities = [] - for index, _ in enumerate(coordinator.data): + for index in range(len(coordinator.data)): entities.append(EldesConnectionStatusBinarySensor(client, coordinator, index)) async_add_entities(entities) @@ -40,7 +40,7 @@ def unique_id(self): @property def name(self): - return f"{self.data["info"]["model"]} Connection Status" + return f"{self.data['info']['model']} Connection Status" @property def is_on(self): diff --git a/custom_components/eldes_alarm/config_flow.py b/custom_components/eldes_alarm/config_flow.py index b2f9d2f..054fa23 100644 --- a/custom_components/eldes_alarm/config_flow.py +++ b/custom_components/eldes_alarm/config_flow.py @@ -1,9 +1,6 @@ """Adds config flow for Eldes.""" import logging -import asyncio -import aiohttp import voluptuous as vol -from http import HTTPStatus from homeassistant import config_entries from homeassistant.core import callback @@ -11,7 +8,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_SCAN_INTERVAL from .core.eldes_cloud import EldesCloud -from .const import DOMAIN, DEFAULT_SCAN_INTERVAL, DEFAULT_EVENTS_LIST_SIZE, CONF_EVENTS_LIST_SIZE +from .const import DOMAIN, DEFAULT_SCAN_INTERVAL, DEFAULT_EVENTS_LIST_SIZE, CONF_EVENTS_LIST_SIZE, CONF_DEVICE_IMEI _LOGGER = logging.getLogger(__name__) @@ -29,66 +26,88 @@ class EldesConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 def __init__(self): - """Start the eldes config flow.""" - self._reauth_entry = None - self._username = None + self.client = None + self.devices = [] + self.data = {} async def async_step_user(self, user_input=None): - """Handle the initial step.""" + """Step 1: collect email and password.""" errors = {} if user_input is not None: - self._username = user_input[CONF_USERNAME] - self._password = user_input[CONF_PASSWORD] - unique_id = self._username.lower() - await self.async_set_unique_id(unique_id) - - session = async_get_clientsession(self.hass) - eldes_client = EldesCloud(session, self._username, self._password) + email = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] try: - await eldes_client.login() - except (asyncio.TimeoutError, aiohttp.ClientResponseError) as err: - if isinstance(err, aiohttp.ClientResponseError) and err.status == HTTPStatus.UNAUTHORIZED: - errors["base"] = "invalid_auth" + session = async_get_clientsession(self.hass) + self.client = EldesCloud(session, email, password) + await self.client.login() + + self.devices = await self.client.get_devices() + if not self.devices: + errors["base"] = "no_devices" else: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - if not self._reauth_entry: - return self.async_create_entry( - title=self._username, data=user_input - ) - self.hass.config_entries.async_update_entry( - self._reauth_entry, data=user_input, unique_id=unique_id - ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + self.data[CONF_USERNAME] = email + self.data[CONF_PASSWORD] = password + return await self.async_step_select_device() + + except Exception as ex: + _LOGGER.error("Eldes login failed: %s", ex) + errors["base"] = "auth_failed" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({ + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + }), + errors=errors + ) + + async def async_step_select_device(self, user_input=None): + """Step 2: let user select device.""" + errors = {} + + device_options = { + device["imei"]: f"{device['name']} ({device['imei']})" + for device in self.devices + } + + if user_input is not None: + selected_imei = user_input["device"] + selected_device = next((d for d in self.devices if d["imei"] == selected_imei), None) + + if selected_device: + await self.async_set_unique_id(selected_imei) + self._abort_if_unique_id_configured() + + self.data[CONF_DEVICE_IMEI] = selected_imei + + return self.async_create_entry( + title=selected_device["name"], + data=self.data ) - return self.async_abort(reason="reauth_successful") + else: + errors["base"] = "device_not_found" return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="select_device", + data_schema=vol.Schema({ + vol.Required("device"): vol.In(device_options) + }), + errors=errors ) @staticmethod @callback def async_get_options_flow(config_entry): - """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle a option flow for Eldes.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry + """Handle the options flow for Eldes.""" async def async_step_init(self, user_input=None): - """Handle options flow.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/custom_components/eldes_alarm/const.py b/custom_components/eldes_alarm/const.py index 9074239..b5e6a71 100644 --- a/custom_components/eldes_alarm/const.py +++ b/custom_components/eldes_alarm/const.py @@ -5,7 +5,7 @@ DATA_CLIENT = "eldes_client" DATA_COORDINATOR = "coordinator" -DATA_DEVICES = "devices" +CONF_DEVICE_IMEI = "device_imei" CONF_EVENTS_LIST_SIZE = "events_list_size" DEFAULT_SCAN_INTERVAL = 30 diff --git a/custom_components/eldes_alarm/core/eldes_cloud.py b/custom_components/eldes_alarm/core/eldes_cloud.py index 5c2afb9..db1585c 100644 --- a/custom_components/eldes_alarm/core/eldes_cloud.py +++ b/custom_components/eldes_alarm/core/eldes_cloud.py @@ -41,7 +41,7 @@ async def _setOAuthHeader(self, data): self.refresh_token = data["refreshToken"] if "token" in data: - self.headers["Authorization"] = f"Bearer {data["token"]}" + self.headers["Authorization"] = f"Bearer {data['token']}" self.token_expires_at = datetime.utcnow() + timedelta(minutes=4) # token lasts 5 minutes, refresh 1 minute before return data @@ -95,7 +95,7 @@ async def login(self): "hostDeviceId": "" } - url = f"{API_URL}{API_PATHS["AUTH"]}login" + url = f"{API_URL}{API_PATHS['AUTH']}login" resp = await self._api_call(url, "POST", data) result = await resp.json() @@ -108,7 +108,7 @@ async def renew_token(self): return self.headers["Authorization"] = f"Bearer {self.refresh_token}" - url = f"{API_URL}{API_PATHS["AUTH"]}token" + url = f"{API_URL}{API_PATHS['AUTH']}token" try: async with async_timeout.timeout(self.timeout): @@ -129,19 +129,19 @@ async def renew_token(self): raise async def get_devices(self): - url = f"{API_URL}{API_PATHS["DEVICE"]}list" + url = f"{API_URL}{API_PATHS['DEVICE']}list" response = await self._safe_api_call(url, "GET") result = await response.json() return result.get("deviceListEntries", []) async def get_device_info(self, imei): - url = f"{API_URL}{API_PATHS["DEVICE"]}info?imei={imei}" + url = f"{API_URL}{API_PATHS['DEVICE']}info?imei={imei}" response = await self._safe_api_call(url, "GET") return await response.json() async def get_device_partitions(self, imei): data = {"imei": imei} - url = f"{API_URL}{API_PATHS["DEVICE"]}partition/list?imei={imei}" + url = f"{API_URL}{API_PATHS['DEVICE']}partition/list?imei={imei}" response = await self._safe_api_call(url, "POST", data) result = await response.json() partitions = result.get("partitions", []) @@ -154,36 +154,36 @@ async def get_device_partitions(self, imei): async def get_device_outputs(self, imei): data = {"imei": imei} - url = f"{API_URL}{API_PATHS["DEVICE"]}list-outputs/{imei}" + url = f"{API_URL}{API_PATHS['DEVICE']}list-outputs/{imei}" response = await self._safe_api_call(url, "POST", data) result = await response.json() return result.get("deviceOutputs", []) async def set_alarm(self, mode, imei, zone_id): data = {"imei": imei, "partitionIndex": zone_id} - url = f"{API_URL}{API_PATHS["DEVICE"]}action/{mode}" + url = f"{API_URL}{API_PATHS['DEVICE']}action/{mode}" response = await self._safe_api_call(url, "POST", data) return await response.text() async def turn_on_output(self, imei, output_id): - url = f"{API_URL}{API_PATHS["DEVICE"]}control/enable/{imei}/{output_id}" + url = f"{API_URL}{API_PATHS['DEVICE']}control/enable/{imei}/{output_id}" response = await self._safe_api_call(url, "PUT", {}) return response async def turn_off_output(self, imei, output_id): - url = f"{API_URL}{API_PATHS["DEVICE"]}control/disable/{imei}/{output_id}" + url = f"{API_URL}{API_PATHS['DEVICE']}control/disable/{imei}/{output_id}" response = await self._safe_api_call(url, "PUT", {}) return response async def get_temperatures(self, imei): - url = f"{API_URL}{API_PATHS["DEVICE"]}temperatures?imei={imei}" + url = f"{API_URL}{API_PATHS['DEVICE']}temperatures?imei={imei}" response = await self._safe_api_call(url, "POST", {}) result = await response.json() return result.get("temperatureDetailsList", []) async def get_events(self, size): data = {"": "", "size": size, "start": 0} - url = f"{API_URL}{API_PATHS["DEVICE"]}event/list" + url = f"{API_URL}{API_PATHS['DEVICE']}event/list" response = await self._safe_api_call(url, "POST", data) result = await response.json() return result.get("eventDetails", []) diff --git a/custom_components/eldes_alarm/sensor.py b/custom_components/eldes_alarm/sensor.py index ff3d3a5..3d266f9 100644 --- a/custom_components/eldes_alarm/sensor.py +++ b/custom_components/eldes_alarm/sensor.py @@ -29,12 +29,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] entities = [] - for index, _ in enumerate(coordinator.data): + for index in range(len(coordinator.data)): entities.append(EldesBatteryStatusSensor(client, coordinator, index)) entities.append(EldesGSMStrengthSensor(client, coordinator, index)) entities.append(EldesPhoneNumberSensor(client, coordinator, index)) entities.append(EventsSensor(client, coordinator, index)) - for temp_index, _ in enumerate(coordinator.data[index]["temp"]): + for temp_index in range(len(coordinator.data[index]["temp"])): entities.append(EldesTemperatureSensor(client, coordinator, index, temp_index)) async_add_entities(entities) @@ -49,7 +49,7 @@ def unique_id(self): @property def name(self): - return f"{self.data["info"]["model"]} Battery Status" + return f"{self.data['info']['model']} Battery Status" @property def icon(self): @@ -69,7 +69,7 @@ def unique_id(self): @property def name(self): - return f"{self.data["info"]["model"]} GSM Strength" + return f"{self.data['info']['model']} GSM Strength" @property def icon(self): @@ -93,7 +93,7 @@ def unique_id(self): @property def name(self): - return f"{self.data["info"]["model"]} Phone Number" + return f"{self.data['info']['model']} Phone Number" @property def icon(self): @@ -113,11 +113,11 @@ def temp(self): @property def unique_id(self): - return f"{self.imei}_{self.temp["sensorName"]}_{self.temp["sensorId"]}_temperature" + return f"{self.imei}_{self.temp['sensorName']}_{self.temp['sensorId']}_temperature" @property def name(self): - return f"{self.temp["sensorName"]} Temperature" + return f"{self.temp['sensorName']} Temperature" @property def device_class(self): diff --git a/custom_components/eldes_alarm/switch.py b/custom_components/eldes_alarm/switch.py index 41d3e81..c81fd7c 100644 --- a/custom_components/eldes_alarm/switch.py +++ b/custom_components/eldes_alarm/switch.py @@ -24,8 +24,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] entities = [] - for device_index, _ in enumerate(coordinator.data): - for output_index, _ in enumerate(coordinator.data[device_index]["outputs"]): + for device_index in range(len(coordinator.data)): + for output_index in range(len(coordinator.data[device_index]["outputs"])): entities.append(EldesSwitch(client, coordinator, device_index, output_index)) async_add_entities(entities) @@ -40,7 +40,7 @@ def output(self): @property def unique_id(self): - return f"{self.imei}_output_{self.output["id"]}" + return f"{self.imei}_output_{self.output['id']}" @property def name(self): diff --git a/custom_components/eldes_alarm/translations/en.json b/custom_components/eldes_alarm/translations/en.json index 08b6703..c0c9a8a 100644 --- a/custom_components/eldes_alarm/translations/en.json +++ b/custom_components/eldes_alarm/translations/en.json @@ -3,26 +3,32 @@ "step": { "user": { "title": "Eldes Cloud Service", - "description": "Please login to your Eldes cloud services account.", + "description": "Please log in to your Eldes account.", "data": { "username": "Email", "password": "Password" } + }, + "select_device": { + "title": "Select a device/location", + "data": { + "device": "Device" + } } }, "error": { - "cannot_connect": "Can't connect. Try again later.", - "invalid_auth": "Email or password is incorrect.", - "unknown": "Unknown error." + "auth_failed": "Login failed. Please check your credentials.", + "no_devices": "No devices found in your Eldes account.", + "device_not_found": "The selected device was not found." } }, "options": { "step": { "init": { "title": "Configure Eldes integration", - "description": "Update the scan interval to poll for data more often.", + "description": "Configure the scan interval and the number of events to be retrieved according to your preferences.", "data": { - "scan_interval": "Scan Interval (seconds)", + "scan_interval": "Scan interval (seconds)", "events_list_size": "Events list size" } } diff --git a/custom_components/eldes_alarm/translations/lt.json b/custom_components/eldes_alarm/translations/lt.json index 8c8605a..f4c0fe5 100644 --- a/custom_components/eldes_alarm/translations/lt.json +++ b/custom_components/eldes_alarm/translations/lt.json @@ -3,26 +3,32 @@ "step": { "user": { "title": "Eldes Cloud Service", - "description": "Prašome prisijungti prie savo Eldes cloud services paskyros.", + "description": "Prisijunkite prie savo Eldes paskyros.", "data": { "username": "El. pašto adresas", "password": "Slaptažodis" } + }, + "select_device": { + "title": "Pasirinkite įrenginį/vietą", + "data": { + "device": "Įrenginys" + } } }, "error": { - "cannot_connect": "Prisijungti nepavyko. Pabandykite dar kartą vėliau.", - "invalid_auth": "Neteisingas el. pašto adresas arba slaptažodis.", - "unknown": "Nežinoma klaida." + "auth_failed": "Prisijungti nepavyko. Patikrinkite prisijungimo duomenis.", + "no_devices": "Jūsų Eldes paskyroje nerasta jokių įrenginių.", + "device_not_found": "Pasirinktas įrenginys nerastas." } }, "options": { "step": { "init": { "title": "Eldes integracijos nustatymai", - "description": "Pakeiskite atnaujinimo intervalą, norėdami dažniau arba rečiau atnaujinti duomenis.", + "description": "Konfigūruokite skanavimo intervalą ir įvykių kiekį pagal savo poreikius.", "data": { - "scan_interval": "Atnaujinimo intervalas (sekundės)", + "scan_interval": "Atnaujinimo intervalas (sekundėmis)", "events_list_size": "Įvykių sąrašo ilgis" } } From 96e357b31e48925a7c3b31d87046e528d915d205 Mon Sep 17 00:00:00 2001 From: Augustas Cirba Date: Wed, 23 Apr 2025 21:51:38 +0300 Subject: [PATCH 06/10] Filter events for current device --- custom_components/eldes_alarm/config_flow.py | 10 +++++++--- custom_components/eldes_alarm/sensor.py | 4 ++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/custom_components/eldes_alarm/config_flow.py b/custom_components/eldes_alarm/config_flow.py index 054fa23..4950041 100644 --- a/custom_components/eldes_alarm/config_flow.py +++ b/custom_components/eldes_alarm/config_flow.py @@ -22,7 +22,6 @@ class EldesConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Eldes.""" - VERSION = 1 def __init__(self): @@ -107,6 +106,11 @@ def async_get_options_flow(config_entry): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle the options flow for Eldes.""" + def __init__(self, config_entry): + """Initialize options flow.""" + super().__init__() + self._config_entry = config_entry + async def async_step_init(self, user_input=None): if user_input is not None: return self.async_create_entry(title="", data=user_input) @@ -117,11 +121,11 @@ async def async_step_init(self, user_input=None): { vol.Required( CONF_SCAN_INTERVAL, - default=self.config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + default=self._config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) ): int, vol.Required( CONF_EVENTS_LIST_SIZE, - default=self.config_entry.options.get(CONF_EVENTS_LIST_SIZE, DEFAULT_EVENTS_LIST_SIZE) + default=self._config_entry.options.get(CONF_EVENTS_LIST_SIZE, DEFAULT_EVENTS_LIST_SIZE) ): int, } ) diff --git a/custom_components/eldes_alarm/sensor.py b/custom_components/eldes_alarm/sensor.py index 3d266f9..23c3ef5 100644 --- a/custom_components/eldes_alarm/sensor.py +++ b/custom_components/eldes_alarm/sensor.py @@ -153,6 +153,10 @@ def extra_state_attributes(self): alarms = [] user_actions = [] for event in self.data.get("events", []): + # Skip events that don't match this device's IMEI + if event.get("imei") != self.imei: + continue + if event["type"] == "ALARM": alarms.append(self.__add_time(event)) elif event["type"] in ("ARM", "DISARM"): From ef4a1ac051c7123c2f986e4859b771ea81a20229 Mon Sep 17 00:00:00 2001 From: Augustas Cirba Date: Wed, 23 Apr 2025 22:07:37 +0300 Subject: [PATCH 07/10] Cleanup & optimizations --- custom_components/eldes_alarm/config_flow.py | 22 ++++++++++++++++--- custom_components/eldes_alarm/const.py | 9 ++++++++ .../eldes_alarm/core/eldes_cloud.py | 12 +++++----- custom_components/eldes_alarm/sensor.py | 7 ++++-- 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/custom_components/eldes_alarm/config_flow.py b/custom_components/eldes_alarm/config_flow.py index 4950041..283bac7 100644 --- a/custom_components/eldes_alarm/config_flow.py +++ b/custom_components/eldes_alarm/config_flow.py @@ -8,7 +8,17 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_SCAN_INTERVAL from .core.eldes_cloud import EldesCloud -from .const import DOMAIN, DEFAULT_SCAN_INTERVAL, DEFAULT_EVENTS_LIST_SIZE, CONF_EVENTS_LIST_SIZE, CONF_DEVICE_IMEI +from .const import ( + DOMAIN, + DEFAULT_SCAN_INTERVAL, + DEFAULT_EVENTS_LIST_SIZE, + CONF_EVENTS_LIST_SIZE, + CONF_DEVICE_IMEI, + SCAN_INTERVAL_MIN, + SCAN_INTERVAL_MAX, + EVENTS_LIST_SIZE_MIN, + EVENTS_LIST_SIZE_MAX, +) _LOGGER = logging.getLogger(__name__) @@ -122,11 +132,17 @@ async def async_step_init(self, user_input=None): vol.Required( CONF_SCAN_INTERVAL, default=self._config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ): int, + ): vol.All( + int, + vol.Range(min=SCAN_INTERVAL_MIN, max=SCAN_INTERVAL_MAX) + ), vol.Required( CONF_EVENTS_LIST_SIZE, default=self._config_entry.options.get(CONF_EVENTS_LIST_SIZE, DEFAULT_EVENTS_LIST_SIZE) - ): int, + ): vol.All( + int, + vol.Range(min=EVENTS_LIST_SIZE_MIN, max=EVENTS_LIST_SIZE_MAX) + ), } ) ) diff --git a/custom_components/eldes_alarm/const.py b/custom_components/eldes_alarm/const.py index b5e6a71..38bd4d4 100644 --- a/custom_components/eldes_alarm/const.py +++ b/custom_components/eldes_alarm/const.py @@ -7,6 +7,10 @@ DATA_COORDINATOR = "coordinator" CONF_DEVICE_IMEI = "device_imei" CONF_EVENTS_LIST_SIZE = "events_list_size" +SCAN_INTERVAL_MIN = 10 +SCAN_INTERVAL_MAX = 300 +EVENTS_LIST_SIZE_MIN = 5 +EVENTS_LIST_SIZE_MAX = 50 DEFAULT_SCAN_INTERVAL = 30 DEFAULT_EVENTS_LIST_SIZE = 10 @@ -52,3 +56,8 @@ ATTR_EVENTS = "events" ATTR_ALARMS = "alarms" ATTR_USER_ACTIONS = "user_actions" + +EVENT_TYPE_ALARM = "ALARM" +EVENT_TYPE_ARM = "ARM" +EVENT_TYPE_DISARM = "DISARM" + diff --git a/custom_components/eldes_alarm/core/eldes_cloud.py b/custom_components/eldes_alarm/core/eldes_cloud.py index db1585c..3848e35 100644 --- a/custom_components/eldes_alarm/core/eldes_cloud.py +++ b/custom_components/eldes_alarm/core/eldes_cloud.py @@ -29,8 +29,8 @@ def __init__(self, session: aiohttp.ClientSession, username: str, password: str) "X-Requested-With": "XMLHttpRequest", "x-whitelable": "eldes" } - self.refresh_token = "" - self.token_expires_at = None + self._refresh_token = "" + self._token_expires_at = None self._http_session = session self._username = username @@ -38,11 +38,11 @@ def __init__(self, session: aiohttp.ClientSession, username: str, password: str) async def _setOAuthHeader(self, data): if "refreshToken" in data: - self.refresh_token = data["refreshToken"] + self._refresh_token = data["refreshToken"] if "token" in data: self.headers["Authorization"] = f"Bearer {data['token']}" - self.token_expires_at = datetime.utcnow() + timedelta(minutes=4) # token lasts 5 minutes, refresh 1 minute before + self._token_expires_at = datetime.utcnow() + timedelta(minutes=4) # token lasts 5 minutes, refresh 1 minute before return data @@ -103,11 +103,11 @@ async def login(self): return await self._setOAuthHeader(result) async def renew_token(self): - if not self.token_expires_at or datetime.utcnow() < self.token_expires_at: + if not self._token_expires_at or datetime.utcnow() < self._token_expires_at: _LOGGER.debug("Token is still valid; skipping token refresh.") return - self.headers["Authorization"] = f"Bearer {self.refresh_token}" + self.headers["Authorization"] = f"Bearer {self._refresh_token}" url = f"{API_URL}{API_PATHS['AUTH']}token" try: diff --git a/custom_components/eldes_alarm/sensor.py b/custom_components/eldes_alarm/sensor.py index 23c3ef5..d712056 100644 --- a/custom_components/eldes_alarm/sensor.py +++ b/custom_components/eldes_alarm/sensor.py @@ -17,6 +17,9 @@ ATTR_EVENTS, ATTR_ALARMS, ATTR_USER_ACTIONS, + EVENT_TYPE_ALARM, + EVENT_TYPE_ARM, + EVENT_TYPE_DISARM, ) from . import EldesDeviceEntity @@ -157,9 +160,9 @@ def extra_state_attributes(self): if event.get("imei") != self.imei: continue - if event["type"] == "ALARM": + if event["type"] == EVENT_TYPE_ALARM: alarms.append(self.__add_time(event)) - elif event["type"] in ("ARM", "DISARM"): + elif event["type"] in (EVENT_TYPE_ARM, EVENT_TYPE_DISARM): user_actions.append(self.__add_time_and_name(event)) else: events.append(self.__add_time(event)) From 4b373dc71d82ee6f34bbd5853509290cfd95dbc5 Mon Sep 17 00:00:00 2001 From: Augustas Cirba Date: Thu, 24 Apr 2025 19:02:45 +0300 Subject: [PATCH 08/10] Events filtering fix --- custom_components/eldes_alarm/__init__.py | 2 +- custom_components/eldes_alarm/core/eldes_cloud.py | 4 ++-- custom_components/eldes_alarm/sensor.py | 4 ---- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/custom_components/eldes_alarm/__init__.py b/custom_components/eldes_alarm/__init__.py index 46f38bf..6504607 100644 --- a/custom_components/eldes_alarm/__init__.py +++ b/custom_components/eldes_alarm/__init__.py @@ -97,7 +97,7 @@ async def async_fetch_device_data(eldes_client: EldesCloud, imei: str, entry: Co "partitions": await eldes_client.get_device_partitions(imei), "outputs": await eldes_client.get_device_outputs(imei), "temp": await eldes_client.get_temperatures(imei), - "events": await eldes_client.get_events(events_list_size), + "events": await eldes_client.get_events(imei, events_list_size), } return device diff --git a/custom_components/eldes_alarm/core/eldes_cloud.py b/custom_components/eldes_alarm/core/eldes_cloud.py index 3848e35..4aace4e 100644 --- a/custom_components/eldes_alarm/core/eldes_cloud.py +++ b/custom_components/eldes_alarm/core/eldes_cloud.py @@ -181,8 +181,8 @@ async def get_temperatures(self, imei): result = await response.json() return result.get("temperatureDetailsList", []) - async def get_events(self, size): - data = {"": "", "size": size, "start": 0} + async def get_events(self, imei, size): + data = {"": "", "imei": imei, "size": size, "start": 0} url = f"{API_URL}{API_PATHS['DEVICE']}event/list" response = await self._safe_api_call(url, "POST", data) result = await response.json() diff --git a/custom_components/eldes_alarm/sensor.py b/custom_components/eldes_alarm/sensor.py index d712056..ddd84ee 100644 --- a/custom_components/eldes_alarm/sensor.py +++ b/custom_components/eldes_alarm/sensor.py @@ -156,10 +156,6 @@ def extra_state_attributes(self): alarms = [] user_actions = [] for event in self.data.get("events", []): - # Skip events that don't match this device's IMEI - if event.get("imei") != self.imei: - continue - if event["type"] == EVENT_TYPE_ALARM: alarms.append(self.__add_time(event)) elif event["type"] in (EVENT_TYPE_ARM, EVENT_TYPE_DISARM): From c1f5cf9001dacd9b94cf58c7137cc2f170a6200e Mon Sep 17 00:00:00 2001 From: Augustas Cirba Date: Thu, 24 Apr 2025 19:38:02 +0300 Subject: [PATCH 09/10] Added pin code requirement + new translations --- custom_components/eldes_alarm/__init__.py | 5 ++- custom_components/eldes_alarm/config_flow.py | 8 ++-- .../eldes_alarm/core/eldes_cloud.py | 22 ++++++----- .../eldes_alarm/translations/de.json | 38 +++++++++++++++++++ .../eldes_alarm/translations/en.json | 3 +- .../eldes_alarm/translations/fr.json | 38 +++++++++++++++++++ .../eldes_alarm/translations/lt.json | 3 +- .../eldes_alarm/translations/ru.json | 38 +++++++++++++++++++ 8 files changed, 139 insertions(+), 16 deletions(-) create mode 100644 custom_components/eldes_alarm/translations/de.json create mode 100644 custom_components/eldes_alarm/translations/fr.json create mode 100644 custom_components/eldes_alarm/translations/ru.json diff --git a/custom_components/eldes_alarm/__init__.py b/custom_components/eldes_alarm/__init__.py index 6504607..9aa382b 100644 --- a/custom_components/eldes_alarm/__init__.py +++ b/custom_components/eldes_alarm/__init__.py @@ -7,7 +7,7 @@ from aiohttp import ClientResponseError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_SCAN_INTERVAL +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_PIN, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, ConfigEntryAuthFailed from homeassistant.helpers import config_validation as cv @@ -42,11 +42,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Eldes from a config entry.""" username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] + pin = entry.data[CONF_PIN] selected_imei = entry.data[CONF_DEVICE_IMEI] scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) session = async_get_clientsession(hass) - eldes_client = EldesCloud(session, username, password) + eldes_client = EldesCloud(session, username, password, pin) try: await eldes_client.login() diff --git a/custom_components/eldes_alarm/config_flow.py b/custom_components/eldes_alarm/config_flow.py index 283bac7..cae18d6 100644 --- a/custom_components/eldes_alarm/config_flow.py +++ b/custom_components/eldes_alarm/config_flow.py @@ -5,7 +5,7 @@ from homeassistant import config_entries from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_SCAN_INTERVAL +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_PIN, CONF_SCAN_INTERVAL from .core.eldes_cloud import EldesCloud from .const import ( @@ -25,7 +25,8 @@ DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_PIN): str } ) @@ -46,10 +47,11 @@ async def async_step_user(self, user_input=None): if user_input is not None: email = user_input[CONF_USERNAME] password = user_input[CONF_PASSWORD] + pin = user_input[CONF_PIN] try: session = async_get_clientsession(self.hass) - self.client = EldesCloud(session, email, password) + self.client = EldesCloud(session, email, password, pin) await self.client.login() self.devices = await self.client.get_devices() diff --git a/custom_components/eldes_alarm/core/eldes_cloud.py b/custom_components/eldes_alarm/core/eldes_cloud.py index 4aace4e..75b459e 100644 --- a/custom_components/eldes_alarm/core/eldes_cloud.py +++ b/custom_components/eldes_alarm/core/eldes_cloud.py @@ -23,8 +23,8 @@ class EldesCloud: """Interacts with Eldes via public API.""" - def __init__(self, session: aiohttp.ClientSession, username: str, password: str): - self.timeout = 30 + def __init__(self, session: aiohttp.ClientSession, username: str, password: str, pin: str): + self.timeout = 15 self.headers = { "X-Requested-With": "XMLHttpRequest", "x-whitelable": "eldes" @@ -35,6 +35,7 @@ def __init__(self, session: aiohttp.ClientSession, username: str, password: str) self._http_session = session self._username = username self._password = password + self._pin = pin async def _setOAuthHeader(self, data): if "refreshToken" in data: @@ -140,7 +141,7 @@ async def get_device_info(self, imei): return await response.json() async def get_device_partitions(self, imei): - data = {"imei": imei} + data = {"imei": imei, "pin": self._pin} url = f"{API_URL}{API_PATHS['DEVICE']}partition/list?imei={imei}" response = await self._safe_api_call(url, "POST", data) result = await response.json() @@ -153,36 +154,39 @@ async def get_device_partitions(self, imei): return partitions async def get_device_outputs(self, imei): - data = {"imei": imei} + data = {"imei": imei, "pin": self._pin} url = f"{API_URL}{API_PATHS['DEVICE']}list-outputs/{imei}" response = await self._safe_api_call(url, "POST", data) result = await response.json() return result.get("deviceOutputs", []) async def set_alarm(self, mode, imei, zone_id): - data = {"imei": imei, "partitionIndex": zone_id} + data = {"imei": imei, "partitionIndex": zone_id, "pin": self._pin} url = f"{API_URL}{API_PATHS['DEVICE']}action/{mode}" response = await self._safe_api_call(url, "POST", data) return await response.text() async def turn_on_output(self, imei, output_id): + data = {"": "", "pin": self._pin} url = f"{API_URL}{API_PATHS['DEVICE']}control/enable/{imei}/{output_id}" - response = await self._safe_api_call(url, "PUT", {}) + response = await self._safe_api_call(url, "PUT", data) return response async def turn_off_output(self, imei, output_id): + data = {"": "", "pin": self._pin} url = f"{API_URL}{API_PATHS['DEVICE']}control/disable/{imei}/{output_id}" - response = await self._safe_api_call(url, "PUT", {}) + response = await self._safe_api_call(url, "PUT", data) return response async def get_temperatures(self, imei): + data = {"": "", "pin": self._pin} url = f"{API_URL}{API_PATHS['DEVICE']}temperatures?imei={imei}" - response = await self._safe_api_call(url, "POST", {}) + response = await self._safe_api_call(url, "POST", data) result = await response.json() return result.get("temperatureDetailsList", []) async def get_events(self, imei, size): - data = {"": "", "imei": imei, "size": size, "start": 0} + data = {"": "", "imei": imei, "size": size, "start": 0, "pin": self._pin} url = f"{API_URL}{API_PATHS['DEVICE']}event/list" response = await self._safe_api_call(url, "POST", data) result = await response.json() diff --git a/custom_components/eldes_alarm/translations/de.json b/custom_components/eldes_alarm/translations/de.json new file mode 100644 index 0000000..466545a --- /dev/null +++ b/custom_components/eldes_alarm/translations/de.json @@ -0,0 +1,38 @@ +{ + "config": { + "step": { + "user": { + "title": "Eldes Cloud Service", + "description": "Bitte melden Sie sich bei Ihrem Eldes-Konto an.", + "data": { + "username": "E-Mail", + "password": "Passwort", + "pin": "PIN-Code" + } + }, + "select_device": { + "title": "Wählen Sie ein Gerät/Standort aus", + "data": { + "device": "Gerät" + } + } + }, + "error": { + "auth_failed": "Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Zugangsdaten.", + "no_devices": "Keine Geräte in Ihrem Eldes-Konto gefunden.", + "device_not_found": "Das ausgewählte Gerät wurde nicht gefunden." + } + }, + "options": { + "step": { + "init": { + "title": "Eldes-Integration konfigurieren", + "description": "Konfigurieren Sie das Scan-Intervall und die Anzahl der abzurufenden Ereignisse nach Ihren Wünschen.", + "data": { + "scan_interval": "Scan-Intervall (Sekunden)", + "events_list_size": "Ereignislisten-Größe" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/eldes_alarm/translations/en.json b/custom_components/eldes_alarm/translations/en.json index c0c9a8a..8fcbf71 100644 --- a/custom_components/eldes_alarm/translations/en.json +++ b/custom_components/eldes_alarm/translations/en.json @@ -6,7 +6,8 @@ "description": "Please log in to your Eldes account.", "data": { "username": "Email", - "password": "Password" + "password": "Password", + "pin": "PIN code" } }, "select_device": { diff --git a/custom_components/eldes_alarm/translations/fr.json b/custom_components/eldes_alarm/translations/fr.json new file mode 100644 index 0000000..47ea8a6 --- /dev/null +++ b/custom_components/eldes_alarm/translations/fr.json @@ -0,0 +1,38 @@ +{ + "config": { + "step": { + "user": { + "title": "Service Cloud Eldes", + "description": "Veuillez vous connecter à votre compte Eldes.", + "data": { + "username": "Email", + "password": "Mot de passe", + "pin": "Code PIN" + } + }, + "select_device": { + "title": "Sélectionnez un appareil/emplacement", + "data": { + "device": "Appareil" + } + } + }, + "error": { + "auth_failed": "Échec de la connexion. Veuillez vérifier vos identifiants.", + "no_devices": "Aucun appareil trouvé dans votre compte Eldes.", + "device_not_found": "L'appareil sélectionné n'a pas été trouvé." + } + }, + "options": { + "step": { + "init": { + "title": "Configurer l'intégration Eldes", + "description": "Configurez l'intervalle de balayage et le nombre d'événements à récupérer selon vos préférences.", + "data": { + "scan_interval": "Intervalle de balayage (secondes)", + "events_list_size": "Taille de la liste des événements" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/eldes_alarm/translations/lt.json b/custom_components/eldes_alarm/translations/lt.json index f4c0fe5..8ac1ffc 100644 --- a/custom_components/eldes_alarm/translations/lt.json +++ b/custom_components/eldes_alarm/translations/lt.json @@ -6,7 +6,8 @@ "description": "Prisijunkite prie savo Eldes paskyros.", "data": { "username": "El. pašto adresas", - "password": "Slaptažodis" + "password": "Slaptažodis", + "pin": "PIN kodas" } }, "select_device": { diff --git a/custom_components/eldes_alarm/translations/ru.json b/custom_components/eldes_alarm/translations/ru.json new file mode 100644 index 0000000..e1f324c --- /dev/null +++ b/custom_components/eldes_alarm/translations/ru.json @@ -0,0 +1,38 @@ +{ + "config": { + "step": { + "user": { + "title": "Облачный сервис Eldes", + "description": "Пожалуйста, войдите в свою учетную запись Eldes.", + "data": { + "username": "Электронная почта", + "password": "Пароль", + "pin": "PIN-код" + } + }, + "select_device": { + "title": "Выберите устройство/место", + "data": { + "device": "Устройство" + } + } + }, + "error": { + "auth_failed": "Ошибка входа. Пожалуйста, проверьте ваши учетные данные.", + "no_devices": "В вашей учетной записи Eldes не найдено устройств.", + "device_not_found": "Выбранное устройство не найдено." + } + }, + "options": { + "step": { + "init": { + "title": "Настройка интеграции Eldes", + "description": "Настройте интервал сканирования и количество получаемых событий в соответствии с вашими предпочтениями.", + "data": { + "scan_interval": "Интервал сканирования (секунды)", + "events_list_size": "Размер списка событий" + } + } + } + } +} \ No newline at end of file From 4849dad34e4d0ee627d72098f00be7ab0161b728 Mon Sep 17 00:00:00 2001 From: Augustas Cirba Date: Thu, 24 Apr 2025 22:05:20 +0300 Subject: [PATCH 10/10] Fix for arming/disarming --- .../eldes_alarm/alarm_control_panel.py | 32 ++++++++----------- custom_components/eldes_alarm/config_flow.py | 25 ++++++++++++--- custom_components/eldes_alarm/const.py | 4 +-- .../eldes_alarm/translations/de.json | 5 +-- .../eldes_alarm/translations/en.json | 3 +- .../eldes_alarm/translations/fr.json | 5 +-- .../eldes_alarm/translations/lt.json | 3 +- .../eldes_alarm/translations/ru.json | 5 +-- 8 files changed, 49 insertions(+), 33 deletions(-) diff --git a/custom_components/eldes_alarm/alarm_control_panel.py b/custom_components/eldes_alarm/alarm_control_panel.py index a029103..970f108 100644 --- a/custom_components/eldes_alarm/alarm_control_panel.py +++ b/custom_components/eldes_alarm/alarm_control_panel.py @@ -47,7 +47,7 @@ class EldesAlarmPanel(EldesDeviceEntity, AlarmControlPanelEntity): def __init__(self, client, coordinator, device_index, partition_index): super().__init__(client, coordinator, device_index, partition_index) - self._attr_alarm_state = None + self._previous_state = None @property def partition(self): @@ -70,44 +70,38 @@ def extra_state_attributes(self): "hasUnacceptedPartitionAlarms": self.partition["hasUnacceptedPartitionAlarms"], } - def _handle_coordinator_update(self) -> None: - """Update the state when coordinator provides new data.""" - self._attr_alarm_state = self.partition["state"] - self.async_write_ha_state() + @property + def alarm_state(self) -> AlarmControlPanelState: + return self.partition["state"] - async def _async_set_alarm(self, mode: str, target_state: AlarmControlPanelState, transition_state: AlarmControlPanelState) -> None: - previous_state = self._attr_alarm_state - self._attr_alarm_state = transition_state + async def _async_set_alarm(self, mode: str, transition_state: AlarmControlPanelState) -> None: + self._previous_state = self.partition["state"] + self.partition["state"] = transition_state self.async_write_ha_state() try: - await self.client.renew_token() await self.client.set_alarm(mode, self.imei, self.partition["internalId"]) - self._attr_alarm_state = target_state - self.async_write_ha_state() - await self.coordinator.async_request_refresh() except Exception as ex: _LOGGER.error("Failed to set alarm (%s): %s", mode, ex) - self._attr_alarm_state = previous_state + self.partition["state"] = self._previous_state self.async_write_ha_state() + raise + - async def async_alarm_disarm(self, code: str | None = None) -> None: + async def async_alarm_disarm(self, code=None) -> None: await self._async_set_alarm( ALARM_MODES["DISARM"], - AlarmControlPanelState.DISARMED, AlarmControlPanelState.DISARMING ) - async def async_alarm_arm_away(self, code: str | None = None) -> None: + async def async_alarm_arm_away(self, code=None) -> None: await self._async_set_alarm( ALARM_MODES["ARM_AWAY"], - AlarmControlPanelState.ARMED_AWAY, AlarmControlPanelState.ARMING ) - async def async_alarm_arm_home(self, code: str | None = None) -> None: + async def async_alarm_arm_home(self, code=None) -> None: await self._async_set_alarm( ALARM_MODES["ARM_HOME"], - AlarmControlPanelState.ARMED_HOME, AlarmControlPanelState.ARMING ) diff --git a/custom_components/eldes_alarm/config_flow.py b/custom_components/eldes_alarm/config_flow.py index cae18d6..14852ce 100644 --- a/custom_components/eldes_alarm/config_flow.py +++ b/custom_components/eldes_alarm/config_flow.py @@ -60,6 +60,7 @@ async def async_step_user(self, user_input=None): else: self.data[CONF_USERNAME] = email self.data[CONF_PASSWORD] = password + self.data[CONF_PIN] = pin return await self.async_step_select_device() except Exception as ex: @@ -68,10 +69,7 @@ async def async_step_user(self, user_input=None): return self.async_show_form( step_id="user", - data_schema=vol.Schema({ - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - }), + data_schema=DATA_SCHEMA, errors=errors ) @@ -124,7 +122,22 @@ def __init__(self, config_entry): self._config_entry = config_entry async def async_step_init(self, user_input=None): + """Handle options flow.""" if user_input is not None: + # Extract PIN from user input + new_pin = user_input.pop(CONF_PIN) + + # Update the main config data with new PIN + new_data = dict(self._config_entry.data) + new_data[CONF_PIN] = new_pin + + # Update both data and options + self.hass.config_entries.async_update_entry( + self._config_entry, + data=new_data, + options=user_input + ) + return self.async_create_entry(title="", data=user_input) return self.async_show_form( @@ -145,6 +158,10 @@ async def async_step_init(self, user_input=None): int, vol.Range(min=EVENTS_LIST_SIZE_MIN, max=EVENTS_LIST_SIZE_MAX) ), + vol.Required( + CONF_PIN, + default=self._config_entry.data.get(CONF_PIN) + ): str, } ) ) diff --git a/custom_components/eldes_alarm/const.py b/custom_components/eldes_alarm/const.py index 38bd4d4..c23f513 100644 --- a/custom_components/eldes_alarm/const.py +++ b/custom_components/eldes_alarm/const.py @@ -7,12 +7,12 @@ DATA_COORDINATOR = "coordinator" CONF_DEVICE_IMEI = "device_imei" CONF_EVENTS_LIST_SIZE = "events_list_size" -SCAN_INTERVAL_MIN = 10 +SCAN_INTERVAL_MIN = 5 SCAN_INTERVAL_MAX = 300 EVENTS_LIST_SIZE_MIN = 5 EVENTS_LIST_SIZE_MAX = 50 -DEFAULT_SCAN_INTERVAL = 30 +DEFAULT_SCAN_INTERVAL = 15 DEFAULT_EVENTS_LIST_SIZE = 10 DEFAULT_OUTPUT_ICON = "ICON_1" diff --git a/custom_components/eldes_alarm/translations/de.json b/custom_components/eldes_alarm/translations/de.json index 466545a..057d0a3 100644 --- a/custom_components/eldes_alarm/translations/de.json +++ b/custom_components/eldes_alarm/translations/de.json @@ -30,9 +30,10 @@ "description": "Konfigurieren Sie das Scan-Intervall und die Anzahl der abzurufenden Ereignisse nach Ihren Wünschen.", "data": { "scan_interval": "Scan-Intervall (Sekunden)", - "events_list_size": "Ereignislisten-Größe" + "events_list_size": "Ereignislisten-Größe", + "pin": "PIN-Code" } } } } -} \ No newline at end of file +} diff --git a/custom_components/eldes_alarm/translations/en.json b/custom_components/eldes_alarm/translations/en.json index 8fcbf71..aa613c6 100644 --- a/custom_components/eldes_alarm/translations/en.json +++ b/custom_components/eldes_alarm/translations/en.json @@ -30,7 +30,8 @@ "description": "Configure the scan interval and the number of events to be retrieved according to your preferences.", "data": { "scan_interval": "Scan interval (seconds)", - "events_list_size": "Events list size" + "events_list_size": "Events list size", + "pin": "PIN code" } } } diff --git a/custom_components/eldes_alarm/translations/fr.json b/custom_components/eldes_alarm/translations/fr.json index 47ea8a6..5407522 100644 --- a/custom_components/eldes_alarm/translations/fr.json +++ b/custom_components/eldes_alarm/translations/fr.json @@ -30,9 +30,10 @@ "description": "Configurez l'intervalle de balayage et le nombre d'événements à récupérer selon vos préférences.", "data": { "scan_interval": "Intervalle de balayage (secondes)", - "events_list_size": "Taille de la liste des événements" + "events_list_size": "Taille de la liste des événements", + "pin": "Code PIN" } } } } -} \ No newline at end of file +} diff --git a/custom_components/eldes_alarm/translations/lt.json b/custom_components/eldes_alarm/translations/lt.json index 8ac1ffc..b196ca1 100644 --- a/custom_components/eldes_alarm/translations/lt.json +++ b/custom_components/eldes_alarm/translations/lt.json @@ -30,7 +30,8 @@ "description": "Konfigūruokite skanavimo intervalą ir įvykių kiekį pagal savo poreikius.", "data": { "scan_interval": "Atnaujinimo intervalas (sekundėmis)", - "events_list_size": "Įvykių sąrašo ilgis" + "events_list_size": "Įvykių sąrašo ilgis", + "pin": "PIN kodas" } } } diff --git a/custom_components/eldes_alarm/translations/ru.json b/custom_components/eldes_alarm/translations/ru.json index e1f324c..4df06f4 100644 --- a/custom_components/eldes_alarm/translations/ru.json +++ b/custom_components/eldes_alarm/translations/ru.json @@ -30,9 +30,10 @@ "description": "Настройте интервал сканирования и количество получаемых событий в соответствии с вашими предпочтениями.", "data": { "scan_interval": "Интервал сканирования (секунды)", - "events_list_size": "Размер списка событий" + "events_list_size": "Размер списка событий", + "pin": "PIN-код" } } } } -} \ No newline at end of file +}