From e640c79382ad9a3e34dbbbf10f99d8c4d8d4e348 Mon Sep 17 00:00:00 2001 From: Jean Swart Date: Sat, 8 Oct 2022 22:37:45 +0200 Subject: [PATCH 1/7] Updated devcontainer image --- .devcontainer/devcontainer.json | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f6175ce..7d6ca3a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,25 +1,24 @@ // See https://aka.ms/vscode-remote/devcontainer.json for format details. { - "image": "ludeeus/container:integration", + "image": "ghcr.io/ludeeus/devcontainer/integration:stable", + "name": "Blueprint integration development", "context": "..", "appPort": [ "9123:8123" ], "postCreateCommand": "container install", - "runArgs": [ - "-v", - "${env:HOME}${env:USERPROFILE}/.ssh:/tmp/.ssh" - ], "extensions": [ "ms-python.python", "github.vscode-pull-request-github", - "tabnine.tabnine-vscode" + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance" ], "settings": { "files.eol": "\n", "editor.tabSize": 4, "terminal.integrated.shell.linux": "/bin/bash", "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, "python.linting.pylintEnabled": true, "python.linting.enabled": true, "python.formatting.provider": "black", From 6893da4dbf5711185537d17345a7d358b32d54c3 Mon Sep 17 00:00:00 2001 From: Jean Swart Date: Sat, 8 Oct 2022 22:38:58 +0200 Subject: [PATCH 2/7] Initial migration to EskomSePush API --- .../eskom_loadshedding/__init__.py | 20 +- .../eskom_loadshedding/calendar.py | 161 +++++++++++++ .../eskom_loadshedding/config_flow.py | 117 ++++++++- custom_components/eskom_loadshedding/const.py | 38 ++- .../eskom_loadshedding/entity.py | 44 +--- .../eskom_loadshedding/eskom_interface.py | 142 ++++++----- .../eskom_loadshedding/manifest.json | 4 +- .../eskom_loadshedding/sensor.py | 227 +++++++++++++++++- .../eskom_loadshedding/strings.json | 33 ++- .../eskom_loadshedding/translations/en.json | 33 ++- 10 files changed, 681 insertions(+), 138 deletions(-) create mode 100644 custom_components/eskom_loadshedding/calendar.py diff --git a/custom_components/eskom_loadshedding/__init__.py b/custom_components/eskom_loadshedding/__init__.py index c0d1e4b..e8f1f0e 100644 --- a/custom_components/eskom_loadshedding/__init__.py +++ b/custom_components/eskom_loadshedding/__init__.py @@ -11,8 +11,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import Config, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .eskom_interface import eskom_interface from .const import ( CONF_SCAN_PERIOD, @@ -21,12 +21,13 @@ PLATFORMS, STARTUP_MESSAGE, ) +from .eskom_interface import eskom_interface _LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config: Config): - """Set up this integration using YAML is not supported.""" + """Setting up this integration using YAML is not supported.""" return True @@ -40,7 +41,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): seconds=entry.options.get(CONF_SCAN_PERIOD, DEFAULT_SCAN_PERIOD) ) - coordinator = EskomDataUpdateCoordinator(hass, scan_period) + # Fetch the configured API key and area ID and create the client + api_key = entry.data.get("api_key") + area_id = entry.data.get("area_id") + session = async_get_clientsession(hass) + client = eskom_interface(session=session, api_key=api_key, area_id=area_id) + + coordinator = EskomDataUpdateCoordinator(hass, scan_period, client) await coordinator.async_refresh() if not coordinator.last_update_success: @@ -64,9 +71,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): class EskomDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching data from the API.""" - def __init__(self, hass, scan_period): + def __init__(self, hass, scan_period, client: eskom_interface): """Initialize.""" - self.api = eskom_interface() + self.client = client self.platforms = [] super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=scan_period) @@ -74,8 +81,7 @@ def __init__(self, hass, scan_period): async def _async_update_data(self): """Update data via library.""" try: - data = await self.api.async_get_data() - return data.get("data", {}) + return await self.client.async_get_data() except Exception as exception: raise UpdateFailed(exception) diff --git a/custom_components/eskom_loadshedding/calendar.py b/custom_components/eskom_loadshedding/calendar.py new file mode 100644 index 0000000..363f9a3 --- /dev/null +++ b/custom_components/eskom_loadshedding/calendar.py @@ -0,0 +1,161 @@ +"""Sensor platform for Eskom Loadshedding Interface.""" +from datetime import datetime, timedelta +import re + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent + +from .const import ( + DOMAIN, + LOCAL_EVENTS_ID, + LOCAL_EVENTS_NAME, + LOCAL_SCHEDULE_ID, + LOCAL_SCHEDULE_NAME, +) +from .entity import EskomEntity + + +async def async_setup_entry(hass, entry, async_add_devices): + """Setup calendar platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_devices( + [ + LoadsheddingLocalEventCalendar( + coordinator, + entry, + calendar_id=LOCAL_EVENTS_ID, + friendly_name=LOCAL_EVENTS_NAME, + ), + LoadsheddingLocalScheduleCalendar( + coordinator, + entry, + calendar_id=LOCAL_SCHEDULE_ID, + friendly_name=LOCAL_SCHEDULE_NAME, + ), + ] + ) + + +class LoadsheddingLocalEventCalendar(EskomEntity, CalendarEntity): + """Loadshedding Local Event Calendar class.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator, config_entry, calendar_id: str, friendly_name: str): + """Initialize.""" + self.calendar_id = calendar_id + self.friendly_name = friendly_name + super().__init__(coordinator, config_entry) + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self.config_entry.entry_id}-{self.calendar_id}" + + @property + def name(self): + """Return the friendly name of the sensor.""" + return self.friendly_name + + @property + def event(self): + # Return the next event + events = self.coordinator.data.get("area_information", {}).get("events", {}) + if events: + time_format = "%Y-%m-%dT%H:%M:%S%z" + next_event_start = datetime.strptime(events[0]["start"], time_format) + next_event_end = datetime.strptime(events[0]["end"], time_format) + return CalendarEvent(next_event_start, next_event_end, events[0]["note"]) + + async def async_get_events( + self, + hass, + start_date: datetime, + end_date: datetime, + ) -> list[CalendarEvent]: + # Create calendar events from loadshedding events + events = self.coordinator.data.get("area_information", {}).get("events", {}) + if events: + time_format = "%Y-%m-%dT%H:%M:%S%z" + return [ + CalendarEvent( + start=datetime.strptime(event["start"], time_format), + end=datetime.strptime(event["end"], time_format), + summary=event["note"], + ) + for event in events + ] + else: + return [] + + +class LoadsheddingLocalScheduleCalendar(EskomEntity, CalendarEntity): + """Loadshedding Local Schedule Calendar class.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator, config_entry, calendar_id, friendly_name: str): + """Initialize.""" + self.calendar_id = calendar_id + self.friendly_name = friendly_name + super().__init__(coordinator, config_entry) + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self.config_entry.entry_id}-{self.calendar_id}" + + @property + def name(self): + """Return the friendly name of the sensor.""" + return self.friendly_name + + @property + def event(self): + # Return the next event + events = self.coordinator.data.get("area_information", {}).get("events", {}) + if events: + time_format = "%Y-%m-%dT%H:%M:%S%z" + next_event_start = datetime.strptime(events[0]["start"], time_format) + next_event_end = datetime.strptime(events[0]["end"], time_format) + return CalendarEvent(next_event_start, next_event_end, events[0]["note"]) + + async def async_get_events( + self, + hass, + start_date: datetime, + end_date: datetime, + ) -> list[CalendarEvent]: + # Create calendar events from the loadshedding schedule + schedule = self.coordinator.data.get("area_information", {}).get("schedule", {}) + if schedule: + # Iterate over each day in the schedule and create calender events for each slot + time_format = "%Y-%m-%dT%H:%M%z" + calendar_events = [] + for day in schedule["days"]: + for n, stage in enumerate(day["stages"]): + for time_range in stage: + # Extract the start and end time from the provided time range + times = re.findall(r"\d\d:\d\d", time_range) + + # Create datetimes from the extracted times + start_time = datetime.strptime( + f"{day['date']}T{times[0]}+02:00", time_format + ) + end_time = datetime.strptime( + f"{day['date']}T{times[1]}+02:00", time_format + ) + + # If the end time was earlier than the start time it means that the slot ran into the next day + # i.e. 22:30-00:30 + if end_time < start_time: + end_time += timedelta(days=1) + + calendar_events.append( + CalendarEvent( + start=start_time, end=end_time, summary=f"Stage {n + 1}" + ) + ) + + return calendar_events + else: + return [] diff --git a/custom_components/eskom_loadshedding/config_flow.py b/custom_components/eskom_loadshedding/config_flow.py index bf2e42e..c156321 100644 --- a/custom_components/eskom_loadshedding/config_flow.py +++ b/custom_components/eskom_loadshedding/config_flow.py @@ -3,6 +3,8 @@ from homeassistant import config_entries from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.selector import selector import voluptuous as vol from .const import ( # pylint: disable=unused-import @@ -12,6 +14,7 @@ MIN_SCAN_PERIOD, PLATFORMS, ) +from .eskom_interface import eskom_interface class EskomFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -24,25 +27,125 @@ def __init__(self): """Initialize.""" self._errors = {} - async def async_step_user( - self, user_input=None # pylint: disable=bad-continuation - ): - """Handle a flow initialized by the user.""" + async def async_step_user(self, user_input=None): + self._errors = {} + + if user_input is not None: + # Validate the API key passed in by the user + valid = await self.validate_key(user_input["api_key"]) + if valid: + # Store info to use in next step + self.api_key = user_input["api_key"] + + # Proceed to the next configuration step + return await self.async_step_area_search() + + else: + self._errors["base"] = "auth" + + return await self._show_user_config_form(user_input) + + user_input = {} + user_input["api_key"] = "" + + return await self._show_user_config_form(user_input) + + async def async_step_area_search(self, user_input=None): self._errors = {} - return self.async_create_entry(title="Home", data={},) + if user_input is not None: + # Perform an area search using the user input and check whether any matches were found + areas = await self.search_area(user_input["area_search"]) + + if areas: + # Store the areas for use in the next step + self.area_list = areas["areas"] + return await self.async_step_area_selection() + else: + self._errors["base"] = "bad_area" + + return await self._show_area_config_form(user_input) + + user_input = {} + user_input["area_search"] = "" + + return await self._show_area_config_form(user_input) + + async def async_step_area_selection(self, user_input=None): + self._errors = {} + + if user_input is not None: + if "area_selection" in user_input: + # Create the entry, saving the API key and area ID + return self.async_create_entry( + title="Loadshedding Status", + data={ + "api_key": self.api_key, + "area_id": user_input["area_selection"], + }, + ) + else: + self._errors["base"] = "no_area_selection" + + # Reformat the areas as label/value pairs for the selector + area_options = [ + {"label": f"{item['name']} - {item['region']}", "value": item["id"]} + for item in self.area_list + ] + + data_schema = {} + data_schema["area_selection"] = selector( + {"select": {"options": area_options, "mode": "dropdown"}} + ) + return self.async_show_form( + step_id="area_selection", + data_schema=vol.Schema(data_schema), + errors=self._errors, + ) @staticmethod @callback def async_get_options_flow(config_entry): return EskomOptionsFlowHandler(config_entry) - async def _show_config_form(self, user_input): # pylint: disable=unused-argument + async def _show_user_config_form(self, user_input): + """Show the configuration form.""" + data_schema = {vol.Required("api_key", default=user_input["api_key"]): str} + + return self.async_show_form( + step_id="user", data_schema=vol.Schema(data_schema), errors=self._errors + ) + + async def _show_area_config_form(self, user_input): """Show the configuration form.""" + data_schema = { + vol.Required("area_search", default=user_input["area_search"]): str + } + return self.async_show_form( - step_id="user", data_schema=vol.Schema({}), errors=self._errors, + step_id="area_search", + data_schema=vol.Schema(data_schema), + errors=self._errors, ) + async def validate_key(self, api_key: str) -> bool: + """Validates an EskomSePush API token.""" + # Perform an api allowance check using the provided token + try: + session = async_create_clientsession(self.hass) + interface = eskom_interface(session=session, api_key=api_key) + await interface.async_query_api("/api_allowance") + return True + except Exception: # pylint: disable=broad-except + pass + return False + + async def search_area(self, area_search: str) -> dict: + """Performs an area search using the EskomSePush API""" + session = async_create_clientsession(self.hass) + interface = eskom_interface(session=session, api_key=self.api_key) + return await interface.async_search_areas(area_search) + class EskomOptionsFlowHandler(config_entries.OptionsFlow): """Eskom config flow options handler.""" diff --git a/custom_components/eskom_loadshedding/const.py b/custom_components/eskom_loadshedding/const.py index 6b98c16..b24fd93 100644 --- a/custom_components/eskom_loadshedding/const.py +++ b/custom_components/eskom_loadshedding/const.py @@ -1,6 +1,7 @@ """Constants for eskom loadshedding interface""" # Base component constants NAME = "Eskom Loadshedding Interface" +DEVICE_NAME = "Loadshedding" DOMAIN = "eskom_loadshedding" DOMAIN_DATA = f"{DOMAIN}_data" VERSION = "1.0.6" @@ -8,23 +9,44 @@ ISSUE_URL = "https://github.com/swartjean/ha-eskom-loadshedding/issues" # Icons -ICON = "mdi:lightning-bolt" +STATUS_SENSOR_ICON = "mdi:lightning-bolt" +LOCAL_STATUS_SENSOR_ICON = "mdi:home-lightning-bolt" +QUOTA_SENSOR_ICON = "mdi:cloud-percent" # Platforms SENSOR = "sensor" -PLATFORMS = [SENSOR] +CALENDAR = "calendar" +PLATFORMS = [SENSOR, CALENDAR] # Configuration and options CONF_ENABLED = "enabled" CONF_SCAN_PERIOD = "scan_period" # Defaults -DEFAULT_SCAN_PERIOD = 900 -MIN_SCAN_PERIOD = 300 - -# Defaults -DEFAULT_NAME = DOMAIN - +DEFAULT_SCAN_PERIOD = 7200 +MIN_SCAN_PERIOD = 1800 + +# Entity Identifiers +LOCAL_EVENTS_ID = "calendar_local_events" +LOCAL_SCHEDULE_ID = "calendar_local_schedule" +NATIONAL_STATUS_ID = "national" +CAPE_TOWN_STATUS_ID = "capetown" +NATIONAL_STATUS_AREA_ID = "eskom" +CAPE_TOWN_STATUS_AREA_ID = "capetown" +LOCAL_STATUS_ID = "local" +QUOTA_ID = "api_quota" + +# Entity Names +LOCAL_EVENTS_NAME = "Local Events" +LOCAL_SCHEDULE_NAME = "Local Schedule" +NATIONAL_SATUS_NAME = "National Status" +CAPE_TOWN_STATUS_NAME = "Cape Town Status" +LOCAL_STATUS_NAME = "Local Status" +QUOTA_NAME = "API Quota" + +# API +BASE_API_URL = "https://developer.sepush.co.za/business/2.0" +REQUEST_TIMEOUT_S = 10 STARTUP_MESSAGE = f""" ------------------------------------------------------------------- diff --git a/custom_components/eskom_loadshedding/entity.py b/custom_components/eskom_loadshedding/entity.py index e36f815..64dfad5 100644 --- a/custom_components/eskom_loadshedding/entity.py +++ b/custom_components/eskom_loadshedding/entity.py @@ -1,51 +1,19 @@ """EskomEntity class""" -from homeassistant.helpers import entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, NAME, VERSION +from .const import DEVICE_NAME, DOMAIN, VERSION -class EskomEntity(entity.Entity): +class EskomEntity(CoordinatorEntity): def __init__(self, coordinator, config_entry): - self.coordinator = coordinator + super().__init__(coordinator) self.config_entry = config_entry - @property - def should_poll(self): - """No need to poll. Coordinator notifies entity of updates.""" - return False - - @property - def available(self): - """Return if entity is available.""" - return self.coordinator.last_update_success - - @property - def unique_id(self): - """Return a unique ID to use for this entity.""" - return self.config_entry.entry_id - @property def device_info(self): return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": NAME, + "identifiers": {(DOMAIN, self.config_entry.entry_id)}, + "name": DEVICE_NAME, "model": VERSION, "manufacturer": "swartjean", } - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return { - "stage": self.coordinator.data.get("stage"), - } - - async def async_added_to_hass(self): - """Connect to dispatcher listening for entity data notifications.""" - self.async_on_remove( - self.coordinator.async_add_listener(self.async_write_ha_state) - ) - - async def async_update(self): - """Update entity.""" - await self.coordinator.async_request_refresh() diff --git a/custom_components/eskom_loadshedding/eskom_interface.py b/custom_components/eskom_loadshedding/eskom_interface.py index 898416d..5422d0f 100644 --- a/custom_components/eskom_loadshedding/eskom_interface.py +++ b/custom_components/eskom_loadshedding/eskom_interface.py @@ -1,89 +1,107 @@ -import ssl +import asyncio +import logging +import socket -from aiohttp.client_exceptions import ClientConnectorError, ServerDisconnectedError -from aiohttp_retry import RetryClient +import aiohttp + +from .const import BASE_API_URL, REQUEST_TIMEOUT_S # pylint: disable=unused-import + +_LOGGER: logging.Logger = logging.getLogger(__package__) class eskom_interface: - """Interface class to obtain loadshedding information using the Eskom API""" + """Interface class to obtain loadshedding information using the EskomSePush API""" - def __init__(self): + def __init__( + self, session: aiohttp.ClientSession, api_key: str, area_id: str = None + ): """Initializes class parameters""" - - self.base_url = "https://loadshedding.eskom.co.za/LoadShedding" + self.session = session + self.api_key = api_key + self.area_id = area_id + self.base_url = BASE_API_URL self.headers = { - "user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:69.0) Gecko/20100101 Firefox/69.0" + "Token": api_key, } - self.ssl_context = ssl.create_default_context() - self.ssl_context.set_ciphers("DEFAULT@SECLEVEL=1") - async def async_query_api(self, endpoint, payload=None): - """Queries a given endpoint on the Eskom loadshedding API with the specified payload + async def async_query_api(self, endpoint: str, payload: dict = None): + """Queries a given endpoint on the EskomSePush API with the specified payload Args: - endpoint (string): The endpoint of the Eskom API + endpoint (string): The endpoint of the EskomSePush API payload (dict, optional): The parameters to apply to the query. Defaults to None. Returns: The response object from the request """ - async with RetryClient() as client: - # The Eskom API occasionally drops incoming connections, implement reies - async with client.get( - url=self.base_url + endpoint, + query_url = self.base_url + endpoint + try: + async with self.session.get( + url=query_url, headers=self.headers, params=payload, - ssl=self.ssl_context, - retry_attempts=50, - retry_exceptions={ - ClientConnectorError, - ServerDisconnectedError, - ConnectionError, - OSError, - }, - ) as res: - return await res.json() - - async def async_get_stage(self, attempts=5): - """Fetches the current loadshedding stage from the Eskom API - - Args: - attempts (int, optional): The number of attempts to query a sane value from the Eskom API. Defaults to 5. - - Returns: - The loadshedding stage if the query succeeded, else `None` - """ - - # Placeholder for returned loadshedding stage - api_result = None + timeout=REQUEST_TIMEOUT_S, + ) as resp: + return await resp.json() + except aiohttp.ClientResponseError as exception: + _LOGGER.error( + "Error fetching information from %s. Response code: %s", + query_url, + exception.status, + ) + # Re-raise the ClientResponseError to allow checking for valid headers during config + # These will be caught by the DataUpdateCoordinator + raise + except asyncio.TimeoutError as exception: + _LOGGER.error( + "Timeout fetching information from %s: %s", + query_url, + exception, + ) + except (KeyError, TypeError) as exception: + _LOGGER.error( + "Error parsing information from %s: %s", + query_url, + exception, + ) + except (aiohttp.ClientError, socket.gaierror) as exception: + _LOGGER.error( + "Error fetching information from %s: %s", + query_url, + exception, + ) - # Query the API until a sensible (> 0) value is received, or the number of attempts is exceeded - for attempt in range(attempts): - res = await self.async_query_api("/GetStatus") + async def async_get_status(self) -> dict: + """Fetches the current loadshedding status""" + # Query the API + return await self.async_query_api("/status") - # Check if the API returned a valid response - if res: - # Store the response - api_result = res + async def async_get_allowance(self): + """Fetches the current API allowance""" + # Query the API + return await self.async_query_api("/api_allowance") - # Only return the result if the API returned a non-negative stage, otherwise retry - if int(res) > 0: - # Return the current loadshedding stage by subtracting 1 from the query result - return int(res) - 1 + async def async_get_area_information(self): + """Fetches local loadshedding event information""" + # Query the API + payload = {"id": self.area_id} + return await self.async_query_api("/area", payload=payload) - if api_result: - # If the API is up but returning "invalid" stages (< 0), simply return 0 - return 0 - else: - # If the API the query did not succeed after the number of attempts has been exceeded, raise an exception - raise Exception( - f"Error, no response received from API after {attempts} attempts" - ) + async def async_search_areas(self, area_search: str): + """Searches for areas matching a search string""" + # Query the API + payload = {"text": area_search} + return await self.async_query_api("/areas_search", payload=payload) async def async_get_data(self): - """Fetches data from the loadshedding API""" - stage = await self.async_get_stage() + """Fetches all relevant data from the loadshedding API""" + allowance = await self.async_get_allowance() + status = await self.async_get_status() + area_information = await self.async_get_area_information() + data = { - "data": {"stage": stage}, + "allowance": allowance, + "status": status, + "area_information": area_information, } return data diff --git a/custom_components/eskom_loadshedding/manifest.json b/custom_components/eskom_loadshedding/manifest.json index 4712064..4742cdf 100644 --- a/custom_components/eskom_loadshedding/manifest.json +++ b/custom_components/eskom_loadshedding/manifest.json @@ -9,8 +9,6 @@ "codeowners": [ "@swartjean" ], - "requirements": [ - "aiohttp-retry==1.0" - ], + "requirements": [], "iot_class": "cloud_polling" } \ No newline at end of file diff --git a/custom_components/eskom_loadshedding/sensor.py b/custom_components/eskom_loadshedding/sensor.py index fc85c78..0ad94be 100644 --- a/custom_components/eskom_loadshedding/sensor.py +++ b/custom_components/eskom_loadshedding/sensor.py @@ -1,9 +1,24 @@ """Sensor platform for Eskom Loadshedding Interface.""" +from datetime import datetime +import re + +from homeassistant.components.sensor import SensorEntity + from .const import ( - DEFAULT_NAME, + CAPE_TOWN_STATUS_AREA_ID, + CAPE_TOWN_STATUS_ID, + CAPE_TOWN_STATUS_NAME, DOMAIN, - ICON, - SENSOR, + LOCAL_STATUS_ID, + LOCAL_STATUS_NAME, + LOCAL_STATUS_SENSOR_ICON, + NATIONAL_SATUS_NAME, + NATIONAL_STATUS_AREA_ID, + NATIONAL_STATUS_ID, + QUOTA_ID, + QUOTA_NAME, + QUOTA_SENSOR_ICON, + STATUS_SENSOR_ICON, ) from .entity import EskomEntity @@ -11,23 +26,213 @@ async def async_setup_entry(hass, entry, async_add_devices): """Setup sensor platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices([EskomStageSensor(coordinator, entry)]) + async_add_devices( + [ + LoadsheddingStatusSensor( + coordinator, + entry, + area=NATIONAL_STATUS_AREA_ID, + sensor_id=NATIONAL_STATUS_ID, + friendly_name=NATIONAL_SATUS_NAME, + ), + LoadsheddingStatusSensor( + coordinator, + entry, + area=CAPE_TOWN_STATUS_AREA_ID, + sensor_id=CAPE_TOWN_STATUS_ID, + friendly_name=CAPE_TOWN_STATUS_NAME, + ), + LoadsheddingAreaInfoSensor( + coordinator, + entry, + sensor_id=LOCAL_STATUS_ID, + friendly_name=LOCAL_STATUS_NAME, + ), + LoadsheddingAPIQuotaSensor( + coordinator, + entry, + sensor_id=QUOTA_ID, + friendly_name=QUOTA_NAME, + ), + ] + ) -class EskomStageSensor(EskomEntity): +class LoadsheddingStatusSensor(EskomEntity, SensorEntity): """Eskom Stage Sensor class.""" + _attr_has_entity_name = True + + def __init__( + self, coordinator, config_entry, area: str, sensor_id: str, friendly_name: str + ): + """Initialize.""" + self.area = area + self.sensor_id = sensor_id + self.friendly_name = friendly_name + super().__init__(coordinator, config_entry) + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self.config_entry.entry_id}-{self.sensor_id}" + @property def name(self): - """Return the name of the sensor.""" - return f"{DEFAULT_NAME}_stage" + """Return the friendly name of the sensor.""" + return self.friendly_name @property - def state(self): - """Return the state of the sensor.""" - return self.coordinator.data.get("stage") + def native_value(self): + """Return the native value of the sensor.""" + value = ( + self.coordinator.data.get("status", {}) + .get("status", {}) + .get(self.area, {}) + .get("stage") + ) + if value: + return int(value) + + @property + def icon(self): + """Return the icon of the sensor.""" + return STATUS_SENSOR_ICON + + @property + def extra_state_attributes(self): + # Gather data from coordinator + area_name = ( + self.coordinator.data.get("status", {}) + .get("status", {}) + .get(self.area, {}) + .get("name") + ) + stage_updated = ( + self.coordinator.data.get("status", {}) + .get("status", {}) + .get(self.area, {}) + .get("stage_updated") + ) + + # Convert time strings to datetimes: + time_format = "%Y-%m-%dT%H:%M:%S.%f%z" + time_updated = datetime.strptime(stage_updated, time_format) + return { + "Area Name": area_name, + "Time Updated": time_updated, + } + + +class LoadsheddingAreaInfoSensor(EskomEntity, SensorEntity): + """Eskom Area Info Sensor class.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator, config_entry, sensor_id, friendly_name: str): + """Initialize.""" + self.sensor_id = sensor_id + self.friendly_name = friendly_name + super().__init__(coordinator, config_entry) + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self.config_entry.entry_id}-{self.sensor_id}" + + @property + def name(self): + """Return the friendly name of the sensor.""" + return self.friendly_name + + @property + def native_value(self): + """Return the native value of the sensor.""" + events = self.coordinator.data.get("area_information", {}).get("events", {}) + + if events: + # Extract the first number in the note as the stage for display as an int + # This assumes the note is always formatted as "Stage X" + matches = re.findall(r"\d+", events[0]["note"]) + if matches: + return int(matches[0]) + else: + return events[0]["note"] + else: + return 0 @property def icon(self): """Return the icon of the sensor.""" - return ICON + return LOCAL_STATUS_SENSOR_ICON + + @property + def extra_state_attributes(self): + # Gather data from coordinator + events = self.coordinator.data.get("area_information", {}).get("events", {}) + info = self.coordinator.data.get("area_information", {}).get("info", {}) + + currently_loadshedding = False + + if events: + # Determine whether the area is currently loadshedding + time_format = "%Y-%m-%dT%H:%M:%S%z" + next_event_start = datetime.strptime(events[0]["start"], time_format) + next_event_end = datetime.strptime(events[0]["end"], time_format) + current_time = datetime.now(next_event_start.tzinfo) + currently_loadshedding = next_event_start <= current_time <= next_event_end + + return { + "Area": info["name"], + "Region": info["region"], + "Currently Loadshedding": currently_loadshedding, + } + + +class LoadsheddingAPIQuotaSensor(EskomEntity, SensorEntity): + """Eskom API Quota Sensor class.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator, config_entry, sensor_id, friendly_name: str): + """Initialize.""" + self.sensor_id = sensor_id + self.friendly_name = friendly_name + super().__init__(coordinator, config_entry) + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self.config_entry.entry_id}-{self.sensor_id}" + + @property + def name(self): + """Return the friendly name of the sensor.""" + return self.friendly_name + + @property + def native_value(self): + """Return the native value of the sensor.""" + # Return the number of API calls remaining as the native sensor value + allowance = self.coordinator.data.get("allowance", {}).get("allowance", {}) + + if allowance: + return int(allowance["limit"]) - int(allowance["count"]) + + @property + def icon(self): + """Return the icon of the sensor.""" + return QUOTA_SENSOR_ICON + + @property + def extra_state_attributes(self): + # Gather data from coordinator + allowance = self.coordinator.data.get("allowance", {}).get("allowance", {}) + + if allowance: + return { + "Remaining": int(allowance["limit"]) - int(allowance["count"]), + "Count": int(allowance["count"]), + "Limit": int(allowance["limit"]), + "Type": allowance["type"], + } diff --git a/custom_components/eskom_loadshedding/strings.json b/custom_components/eskom_loadshedding/strings.json index 7bd74f6..fe34d9f 100644 --- a/custom_components/eskom_loadshedding/strings.json +++ b/custom_components/eskom_loadshedding/strings.json @@ -1,10 +1,41 @@ { + "config": { + "error": { + "auth": "The API key provided is not valid.", + "bad_area": "No matching areas found or quota exceeded.", + "no_area_selection": "No area selected." + }, + "step": { + "user": { + "title": "Eskom Loadshedding Interface", + "description": "This integration uses the EskomSePush API (https://sepush.co.za/) to obtain loadshedding data. Please enter your API key to continue:", + "data": { + "api_key": "API Key" + } + }, + "area_search": { + "title": "Eskom Loadshedding Interface", + "description": "Please enter the name of your area, suburb, or municipality:", + "data": { + "area_search": "Area" + } + }, + "area_selection": { + "title": "Eskom Loadshedding Interface", + "description": "Please select your area from the list below:", + "data": { + "area_selection": "Area" + } + } + } + }, "options": { "step": { "user": { "data": { "scan_period": "Scan Period (s)", - "sensor": "Enable Sensor" + "sensor": "Enable Sensors", + "calendar": "Enable Calendars" } } } diff --git a/custom_components/eskom_loadshedding/translations/en.json b/custom_components/eskom_loadshedding/translations/en.json index 7bd74f6..fe34d9f 100644 --- a/custom_components/eskom_loadshedding/translations/en.json +++ b/custom_components/eskom_loadshedding/translations/en.json @@ -1,10 +1,41 @@ { + "config": { + "error": { + "auth": "The API key provided is not valid.", + "bad_area": "No matching areas found or quota exceeded.", + "no_area_selection": "No area selected." + }, + "step": { + "user": { + "title": "Eskom Loadshedding Interface", + "description": "This integration uses the EskomSePush API (https://sepush.co.za/) to obtain loadshedding data. Please enter your API key to continue:", + "data": { + "api_key": "API Key" + } + }, + "area_search": { + "title": "Eskom Loadshedding Interface", + "description": "Please enter the name of your area, suburb, or municipality:", + "data": { + "area_search": "Area" + } + }, + "area_selection": { + "title": "Eskom Loadshedding Interface", + "description": "Please select your area from the list below:", + "data": { + "area_selection": "Area" + } + } + } + }, "options": { "step": { "user": { "data": { "scan_period": "Scan Period (s)", - "sensor": "Enable Sensor" + "sensor": "Enable Sensors", + "calendar": "Enable Calendars" } } } From fa7f11e29394b02b58f85a4dfbd0b7bf8744323f Mon Sep 17 00:00:00 2001 From: Jean Swart Date: Tue, 11 Oct 2022 19:42:03 +0200 Subject: [PATCH 3/7] Updated docs --- README.md | 46 +++++++++++++++++++++++++++++++++++++++++----- info.md | 46 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8a73b7f..8062e65 100644 --- a/README.md +++ b/README.md @@ -8,25 +8,61 @@ [![maintainer][maintenance-shield]][maintainer] [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] -This is a simple component to integrate with the [Eskom Loadshedding API](https://loadshedding.eskom.co.za/LoadShedding) and provide [loadshedding](https://en.wikipedia.org/wiki/South_African_energy_crisis)-related status information. +This component integrates with the [EskomSePush](https://sepush.co.za/) API to provide [loadshedding](https://en.wikipedia.org/wiki/South_African_energy_crisis)-related status information. -This integration exposes a sensor for the current stage of loadshedding. +An EskomSePush API key is required in order to use this integration. Please visit the [EskomSePush website](https://sepush.co.za/) to sign up and view API documentation. -**This component will set up the following platforms.** +**This component will set up the following platforms:** Platform | Description -- | -- -`sensor` | Show loadshedding status information. +`sensor` | Shows loadshedding status information for various areas. +`calendar` | Shows upcoming loadshedding event and schedule information for your area. + +**This component will create the following entities:** + +Entity | Description +-- | -- +`sensor.loadshedding_api_quota` | The EskomSePush API quota associated with your API key. +`sensor.loadshedding_national_status` | The current national loadshedding stage for Eskom-supplied customers. +`sensor.loadshedding_cape_town_status` | The current loadshedding stage for City of Cape Town customers. +`sensor.loadshedding_local_status` | The current loadshedding stage for your specific area. +`calendar.loadshedding_local_events` | Calendar of upcoming loadshedding events for your specific area. +`calendar.loadshedding_local_schedule` | Calendar containing the full 7-day loadshedding schedule for your specific area. + +The component update period defaults to 2 hours in order to avoid excess API quota consumption. This can be edited through the integration configuration, but you are responsible for monitoring your own API usage. + +The recommended way to automate actions around loadshedding events is to use calendar triggers. Below is an example of a simple automation to turn off a switch one hour before any loadshedding event in your area: + +```yaml +alias: Loadshedding Notification +trigger: + - platform: calendar + event: start + entity_id: calendar.loadshedding_local_events + offset: "-1:0:0" +action: + - service: homeassistant.turn_off + data: {} + target: + entity_id: switch.example_device +mode: queued +``` + +Note that by installing this integration you are using it at your own risk. Neither the creators of this integration, nor the EskomSePush team, will be held responsible for any inaccuracies or errors in the loadshedding information presented. ## Installation +**Note that an EskomSePush API key is required in order to use this integration** + 1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). 2. If you do not have a `custom_components` directory (folder) there, you need to create it. 3. In the `custom_components` directory (folder) create a new folder called `eskom_loadshedding`. 4. Download _all_ the files from the `custom_components/eskom_loadshedding/` directory (folder) in this repository. 5. Place the files you downloaded in the new directory (folder) you created. 6. Restart Home Assistant -7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Eskom Loadshedding Interface" +7. In the HA UI go to "Settings" -> "Devices & Services", then click "+ Add Integration" and search for "Eskom Loadshedding Interface" +8. Complete the initial configuration by entering your EskomSePush API key and selecting your loadshedding zone ## Configuration is done in the UI diff --git a/info.md b/info.md index 15869ac..829d3a5 100644 --- a/info.md +++ b/info.md @@ -8,21 +8,57 @@ [![maintainer][maintenance-shield]][maintainer] [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] -This is a simple component to integrate with the [Eskom Loadshedding API](https://loadshedding.eskom.co.za/LoadShedding) and provide [loadshedding](https://en.wikipedia.org/wiki/South_African_energy_crisis)-related status information. +This component integrates with the [EskomSePush](https://sepush.co.za/) API to provide [loadshedding](https://en.wikipedia.org/wiki/South_African_energy_crisis)-related status information. -This integration exposes a sensor for the current stage of loadshedding. +An EskomSePush API key is required in order to use this integration. Please visit the [EskomSePush website](https://sepush.co.za/) to sign up and view API documentation. -**This component will set up the following platforms.** +**This component will set up the following platforms:** Platform | Description -- | -- -`sensor` | Show loadshedding status information. +`sensor` | Shows loadshedding status information for various areas. +`calendar` | Shows upcoming loadshedding event and schedule information for your area. + +**This component will create the following entities:** + +Entity | Description +-- | -- +`sensor.loadshedding_api_quota` | The EskomSePush API quota associated with your API key. +`sensor.loadshedding_national_status` | The current national loadshedding stage for Eskom-supplied customers. +`sensor.loadshedding_cape_town_status` | The current loadshedding stage for City of Cape Town customers. +`sensor.loadshedding_local_status` | The current loadshedding stage for your specific area. +`calendar.loadshedding_local_events` | Calendar of upcoming loadshedding events for your specific area. +`calendar.loadshedding_local_schedule` | Calendar containing the full 7-day loadshedding schedule for your specific area. + +The component update period defaults to 2 hours in order to avoid excess API quota consumption. This can be edited through the integration configuration, but you are responsible for monitoring your own API usage. + +The recommended way to automate actions around loadshedding events is to use calendar triggers. Below is an example of a simple automation to turn off a switch one hour before any loadshedding event in your area: + +```yaml +alias: Loadshedding Notification +trigger: + - platform: calendar + event: start + entity_id: calendar.loadshedding_local_events + offset: "-1:0:0" +action: + - service: homeassistant.turn_off + data: {} + target: + entity_id: switch.example_device +mode: queued +``` + +Note that by installing this integration you are using it at your own risk. Neither the creators of this integration, nor the EskomSePush team, will be held responsible for any inaccuracies or errors in the loadshedding information presented. {% if not installed %} ## Installation +**Note that an EskomSePush API key is required in order to use this integration** + 1. Click install. -1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Eskom Loadshedding Interface". +7. In the HA UI go to "Settings" -> "Devices & Services", then click "+ Add Integration" and search for "Eskom Loadshedding Interface" +3. Complete the initial configuration by entering your EskomSePush API key and selecting your loadshedding zone {% endif %} From 7487a4edd48ed31fd9aafd3af4a6074eb6a9dd4e Mon Sep 17 00:00:00 2001 From: Jean Swart Date: Tue, 11 Oct 2022 19:43:09 +0200 Subject: [PATCH 4/7] Fixed area config step, updated class name --- custom_components/eskom_loadshedding/__init__.py | 6 +++--- .../eskom_loadshedding/config_flow.py | 16 ++++++++++------ .../eskom_loadshedding/eskom_interface.py | 2 +- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/custom_components/eskom_loadshedding/__init__.py b/custom_components/eskom_loadshedding/__init__.py index e8f1f0e..920af18 100644 --- a/custom_components/eskom_loadshedding/__init__.py +++ b/custom_components/eskom_loadshedding/__init__.py @@ -21,7 +21,7 @@ PLATFORMS, STARTUP_MESSAGE, ) -from .eskom_interface import eskom_interface +from .eskom_interface import EskomInterface _LOGGER = logging.getLogger(__name__) @@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): api_key = entry.data.get("api_key") area_id = entry.data.get("area_id") session = async_get_clientsession(hass) - client = eskom_interface(session=session, api_key=api_key, area_id=area_id) + client = EskomInterface(session=session, api_key=api_key, area_id=area_id) coordinator = EskomDataUpdateCoordinator(hass, scan_period, client) await coordinator.async_refresh() @@ -71,7 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): class EskomDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching data from the API.""" - def __init__(self, hass, scan_period, client: eskom_interface): + def __init__(self, hass, scan_period, client: EskomInterface): """Initialize.""" self.client = client self.platforms = [] diff --git a/custom_components/eskom_loadshedding/config_flow.py b/custom_components/eskom_loadshedding/config_flow.py index c156321..ad2b0ff 100644 --- a/custom_components/eskom_loadshedding/config_flow.py +++ b/custom_components/eskom_loadshedding/config_flow.py @@ -14,7 +14,7 @@ MIN_SCAN_PERIOD, PLATFORMS, ) -from .eskom_interface import eskom_interface +from .eskom_interface import EskomInterface class EskomFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -51,6 +51,7 @@ async def async_step_user(self, user_input=None): return await self._show_user_config_form(user_input) async def async_step_area_search(self, user_input=None): + """Collect area search information from the user""" self._errors = {} if user_input is not None: @@ -60,9 +61,11 @@ async def async_step_area_search(self, user_input=None): if areas: # Store the areas for use in the next step self.area_list = areas["areas"] - return await self.async_step_area_selection() - else: - self._errors["base"] = "bad_area" + + if self.area_list: + return await self.async_step_area_selection() + + self._errors["base"] = "bad_area" return await self._show_area_config_form(user_input) @@ -72,6 +75,7 @@ async def async_step_area_search(self, user_input=None): return await self._show_area_config_form(user_input) async def async_step_area_selection(self, user_input=None): + """Collect an area selection from the user""" self._errors = {} if user_input is not None: @@ -133,7 +137,7 @@ async def validate_key(self, api_key: str) -> bool: # Perform an api allowance check using the provided token try: session = async_create_clientsession(self.hass) - interface = eskom_interface(session=session, api_key=api_key) + interface = EskomInterface(session=session, api_key=api_key) await interface.async_query_api("/api_allowance") return True except Exception: # pylint: disable=broad-except @@ -143,7 +147,7 @@ async def validate_key(self, api_key: str) -> bool: async def search_area(self, area_search: str) -> dict: """Performs an area search using the EskomSePush API""" session = async_create_clientsession(self.hass) - interface = eskom_interface(session=session, api_key=self.api_key) + interface = EskomInterface(session=session, api_key=self.api_key) return await interface.async_search_areas(area_search) diff --git a/custom_components/eskom_loadshedding/eskom_interface.py b/custom_components/eskom_loadshedding/eskom_interface.py index 5422d0f..a0c4101 100644 --- a/custom_components/eskom_loadshedding/eskom_interface.py +++ b/custom_components/eskom_loadshedding/eskom_interface.py @@ -9,7 +9,7 @@ _LOGGER: logging.Logger = logging.getLogger(__package__) -class eskom_interface: +class EskomInterface: """Interface class to obtain loadshedding information using the EskomSePush API""" def __init__( From e365e90864c3baad4ab52d74dc055103c0e58ceb Mon Sep 17 00:00:00 2001 From: Jean Swart Date: Tue, 11 Oct 2022 21:33:29 +0200 Subject: [PATCH 5/7] Bump to v1.1.0 --- custom_components/eskom_loadshedding/const.py | 2 +- custom_components/eskom_loadshedding/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/eskom_loadshedding/const.py b/custom_components/eskom_loadshedding/const.py index b24fd93..44169f5 100644 --- a/custom_components/eskom_loadshedding/const.py +++ b/custom_components/eskom_loadshedding/const.py @@ -4,7 +4,7 @@ DEVICE_NAME = "Loadshedding" DOMAIN = "eskom_loadshedding" DOMAIN_DATA = f"{DOMAIN}_data" -VERSION = "1.0.6" +VERSION = "1.1.0" ISSUE_URL = "https://github.com/swartjean/ha-eskom-loadshedding/issues" diff --git a/custom_components/eskom_loadshedding/manifest.json b/custom_components/eskom_loadshedding/manifest.json index 4742cdf..48c4d48 100644 --- a/custom_components/eskom_loadshedding/manifest.json +++ b/custom_components/eskom_loadshedding/manifest.json @@ -5,7 +5,7 @@ "issue_tracker": "https://github.com/swartjean/ha-eskom-loadshedding/issues", "dependencies": [], "config_flow": true, - "version": "1.0.6", + "version": "1.1.0", "codeowners": [ "@swartjean" ], From e221403b95a09d282a085237d195a4a100c0dbd6 Mon Sep 17 00:00:00 2001 From: Jean Swart Date: Tue, 11 Oct 2022 21:53:34 +0200 Subject: [PATCH 6/7] Remove title from user step --- custom_components/eskom_loadshedding/strings.json | 1 - custom_components/eskom_loadshedding/translations/en.json | 1 - 2 files changed, 2 deletions(-) diff --git a/custom_components/eskom_loadshedding/strings.json b/custom_components/eskom_loadshedding/strings.json index fe34d9f..1f7dda7 100644 --- a/custom_components/eskom_loadshedding/strings.json +++ b/custom_components/eskom_loadshedding/strings.json @@ -7,7 +7,6 @@ }, "step": { "user": { - "title": "Eskom Loadshedding Interface", "description": "This integration uses the EskomSePush API (https://sepush.co.za/) to obtain loadshedding data. Please enter your API key to continue:", "data": { "api_key": "API Key" diff --git a/custom_components/eskom_loadshedding/translations/en.json b/custom_components/eskom_loadshedding/translations/en.json index fe34d9f..1f7dda7 100644 --- a/custom_components/eskom_loadshedding/translations/en.json +++ b/custom_components/eskom_loadshedding/translations/en.json @@ -7,7 +7,6 @@ }, "step": { "user": { - "title": "Eskom Loadshedding Interface", "description": "This integration uses the EskomSePush API (https://sepush.co.za/) to obtain loadshedding data. Please enter your API key to continue:", "data": { "api_key": "API Key" From c5bec8aabf95ac896983533d82b66e63e612f51e Mon Sep 17 00:00:00 2001 From: Jean Swart Date: Tue, 11 Oct 2022 21:59:38 +0200 Subject: [PATCH 7/7] Removed more titles --- custom_components/eskom_loadshedding/strings.json | 2 -- custom_components/eskom_loadshedding/translations/en.json | 2 -- 2 files changed, 4 deletions(-) diff --git a/custom_components/eskom_loadshedding/strings.json b/custom_components/eskom_loadshedding/strings.json index 1f7dda7..8626b06 100644 --- a/custom_components/eskom_loadshedding/strings.json +++ b/custom_components/eskom_loadshedding/strings.json @@ -13,14 +13,12 @@ } }, "area_search": { - "title": "Eskom Loadshedding Interface", "description": "Please enter the name of your area, suburb, or municipality:", "data": { "area_search": "Area" } }, "area_selection": { - "title": "Eskom Loadshedding Interface", "description": "Please select your area from the list below:", "data": { "area_selection": "Area" diff --git a/custom_components/eskom_loadshedding/translations/en.json b/custom_components/eskom_loadshedding/translations/en.json index 1f7dda7..8626b06 100644 --- a/custom_components/eskom_loadshedding/translations/en.json +++ b/custom_components/eskom_loadshedding/translations/en.json @@ -13,14 +13,12 @@ } }, "area_search": { - "title": "Eskom Loadshedding Interface", "description": "Please enter the name of your area, suburb, or municipality:", "data": { "area_search": "Area" } }, "area_selection": { - "title": "Eskom Loadshedding Interface", "description": "Please select your area from the list below:", "data": { "area_selection": "Area"