diff --git a/custom_components/ohme/__init__.py b/custom_components/ohme/__init__.py index 185fcbd..852d6ed 100644 --- a/custom_components/ohme/__init__.py +++ b/custom_components/ohme/__init__.py @@ -84,11 +84,8 @@ async def async_setup_entry(hass, entry): hass.data[DOMAIN][DATA_COORDINATORS] = coordinators - # Create tasks for each entity type - for entity_type in ENTITY_TYPES: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, entity_type) - ) + # Setup entities + await hass.config_entries.async_forward_entry_setups(entry, ENTITY_TYPES) entry.async_on_unload(entry.add_update_listener(async_update_listener)) diff --git a/custom_components/ohme/api_client.py b/custom_components/ohme/api_client.py index 096d472..dd743c6 100644 --- a/custom_components/ohme/api_client.py +++ b/custom_components/ohme/api_client.py @@ -191,9 +191,9 @@ async def async_approve_charge(self): result = await self._put_request(f"/v1/chargeSessions/{self._serial}/approve?approve=true") return bool(result) - async def async_max_charge(self): + async def async_max_charge(self, state=True): """Enable max charge""" - result = await self._put_request(f"/v1/chargeSessions/{self._serial}/rule?maxCharge=true") + result = await self._put_request(f"/v1/chargeSessions/{self._serial}/rule?maxCharge=" + str(state).lower()) return bool(result) async def async_apply_session_rule(self, max_price=None, target_time=None, target_percent=None, pre_condition=None, pre_condition_length=None): diff --git a/custom_components/ohme/binary_sensor.py b/custom_components/ohme/binary_sensor.py index 5a50a91..9ac2e8d 100644 --- a/custom_components/ohme/binary_sensor.py +++ b/custom_components/ohme/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.util.dt import (utcnow) from .const import DOMAIN, DATA_COORDINATORS, DATA_SLOTS, COORDINATOR_CHARGESESSIONS, COORDINATOR_ADVANCED, DATA_CLIENT from .coordinator import OhmeChargeSessionsCoordinator, OhmeAdvancedSettingsCoordinator -from .utils import charge_graph_in_slot +from .utils import in_slot _LOGGER = logging.getLogger(__name__) @@ -137,8 +137,7 @@ def _calculate_state(self) -> bool: return power > 0 # See if we are in a charge slot now and if we were for the last reading - in_charge_slot = charge_graph_in_slot( - self.coordinator.data['startTime'], self.coordinator.data['chargeGraph']['points']) + in_charge_slot = in_slot(self.coordinator.data) lr_in_charge_slot = self._last_reading_in_slot # Store this for next time self._last_reading_in_slot = in_charge_slot @@ -313,8 +312,7 @@ def _handle_coordinator_update(self) -> None: elif self.coordinator.data["mode"] == "DISCONNECTED": self._state = False else: - self._state = charge_graph_in_slot( - self.coordinator.data['startTime'], self.coordinator.data['chargeGraph']['points']) + self._state = in_slot(self.coordinator.data) self._last_updated = utcnow() diff --git a/custom_components/ohme/const.py b/custom_components/ohme/const.py index 0aa3744..b4d8ca8 100644 --- a/custom_components/ohme/const.py +++ b/custom_components/ohme/const.py @@ -1,7 +1,7 @@ """Component constants""" DOMAIN = "ohme" USER_AGENT = "dan-r-homeassistant-ohme" -INTEGRATION_VERSION = "0.8.3" +INTEGRATION_VERSION = "0.9.0" CONFIG_VERSION = 1 ENTITY_TYPES = ["sensor", "binary_sensor", "switch", "button", "number", "time"] diff --git a/custom_components/ohme/sensor.py b/custom_components/ohme/sensor.py index 803ebb8..73f3d8c 100644 --- a/custom_components/ohme/sensor.py +++ b/custom_components/ohme/sensor.py @@ -15,7 +15,7 @@ from homeassistant.util.dt import (utcnow) from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, DATA_SLOTS, COORDINATOR_CHARGESESSIONS, COORDINATOR_STATISTICS, COORDINATOR_ADVANCED from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator, OhmeAdvancedSettingsCoordinator -from .utils import charge_graph_next_slot, charge_graph_slot_list, get_option +from .utils import next_slot, get_option, slot_list _LOGGER = logging.getLogger(__name__) @@ -360,8 +360,7 @@ def _handle_coordinator_update(self) -> None: if self.coordinator.data is None or self.coordinator.data["mode"] == "DISCONNECTED": self._state = None else: - self._state = charge_graph_next_slot( - self.coordinator.data['startTime'], self.coordinator.data['chargeGraph']['points'])['start'] + self._state = next_slot(self.coordinator.data)['start'] self._last_updated = utcnow() @@ -412,8 +411,7 @@ def _handle_coordinator_update(self) -> None: if self.coordinator.data is None or self.coordinator.data["mode"] == "DISCONNECTED": self._state = None else: - self._state = charge_graph_next_slot( - self.coordinator.data['startTime'], self.coordinator.data['chargeGraph']['points'])['end'] + self._state = next_slot(self.coordinator.data)['end'] self._last_updated = utcnow() @@ -463,10 +461,8 @@ def _handle_coordinator_update(self) -> None: """Get a list of charge slots.""" if self.coordinator.data is None or self.coordinator.data["mode"] == "DISCONNECTED" or self.coordinator.data["mode"] == "FINISHED_CHARGE": self._state = None - self._hass.data[DOMAIN][DATA_SLOTS] = [] else: - slots = charge_graph_slot_list( - self.coordinator.data['startTime'], self.coordinator.data['chargeGraph']['points']) + slots = slot_list(self.coordinator.data) # Store slots for external use self._hass.data[DOMAIN][DATA_SLOTS] = slots diff --git a/custom_components/ohme/switch.py b/custom_components/ohme/switch.py index 5d03e58..498f4ea 100644 --- a/custom_components/ohme/switch.py +++ b/custom_components/ohme/switch.py @@ -152,7 +152,7 @@ def _handle_coordinator_update(self) -> None: async def async_turn_on(self): """Turn on the switch.""" - await self._client.async_max_charge() + await self._client.async_max_charge(True) # Not very graceful but wait here to avoid the mode coming back as 'CALCULATING' # It would be nice to simply ignore this state in future and try again after x seconds. @@ -162,7 +162,7 @@ async def async_turn_on(self): async def async_turn_off(self): """Stop max charging. We are not changing anything, just applying the last rule. No need to supply anything.""" - await self._client.async_apply_session_rule() + await self._client.async_max_charge(False) await asyncio.sleep(1) await self.coordinator.async_refresh() diff --git a/custom_components/ohme/utils.py b/custom_components/ohme/utils.py index 719a8ff..baf4bd8 100644 --- a/custom_components/ohme/utils.py +++ b/custom_components/ohme/utils.py @@ -5,160 +5,65 @@ # import logging # _LOGGER = logging.getLogger(__name__) -def _format_charge_graph(charge_start, points): - """Convert relative time in points array to real timestamp (s).""" - charge_start = round(charge_start / 1000) - - # _LOGGER.debug("Charge slot graph points: " + str([{"t": datetime.fromtimestamp(x["x"] + charge_start).strftime('%H:%M:%S'), "y": x["y"]} for x in points])) - - return [{"t": x["x"] + charge_start, "y": x["y"]} for x in points] - - -def _sanitise_points(points): - """Discard any points that aren't on a quarter-hour boundary.""" - output = [] - seen = [] - high = max([x['y'] for x in points]) - - points.reverse() - - for point in points: - # Round up the timestamp and get the minute - ts = point['t'] + 30 - dt = datetime.fromtimestamp(ts) - hm = dt.strftime('%H:%M') - m = int(dt.strftime('%M')) - - # If this point is on a 15m boundary and we haven't seen this time before - # OR y == yMax - so we don't miss the end of the last slot - if (m % 15 == 0 and hm not in seen) or point['y'] == high: - output.append(point) - seen.append(hm) - - output.reverse() - # _LOGGER.warning("Charge slot graph points: " + str([{"t": datetime.fromtimestamp(x["t"] + 30).strftime('%H:%M:%S'), "y": x["y"]} for x in output])) - - return output - - -def _next_slot(data, live=False, in_progress=False): - """Get the next slot. live is whether or not we may start mid charge. Eg: For the next slot end sensor, we dont have the - start but still want the end of the in progress session, but for the slot list sensor we only want slots that have - a start AND an end.""" - start_ts = None - start_ts_y = 0 - end_ts = None - end_ts_y = 0 - - # Loop through every remaining value, skipping the last - for idx in range(0, len(data) - 1): - # Calculate the delta between this element and the next - delta = data[idx + 1]["y"] - data[idx]["y"] - delta = 0 if delta < 0 else delta # Zero floor deltas - - # If the next point has a Y delta of 10+, consider this the start of a slot - # This should be 0+ but I had some strange results in testing... revisit - if delta > 10 and not start_ts: - # 1s added here as it otherwise often rounds down to xx:59:59 - start_ts = data[idx]["t"] + 1 - start_ts_y = data[idx]["y"] - - # If we are working live, in a time slot and haven't seen an end yet, - # disregard. - if start_ts and live and in_progress and not end_ts: - start_ts = None - - # Take the first delta of 0 as the end - if delta == 0 and data[idx]["y"] != 0 and (start_ts or live) and not end_ts: - end_ts = data[idx]["t"] + 1 - end_ts_y = data[idx]["y"] - - if start_ts and end_ts: - break +def next_slot(data): + """Get the next charge slot start/end times.""" + slots = slot_list(data) + start = None + end = None + + # Loop through slots + for slot in slots: + # Only take the first slot start/end that matches. These are in order. + if start is None and slot['start'] > datetime.now().astimezone(): + start = slot['start'] + if end is None and slot['end'] > datetime.now().astimezone(): + end = slot['end'] - return [start_ts, end_ts, idx, end_ts_y - start_ts_y] - - -def charge_graph_next_slot(charge_start, points, skip_format=False): - """Get the next charge slot start/end times from a list of graph points.""" - now = int(time()) - data = points if skip_format else _format_charge_graph(charge_start, points) - in_progress = charge_graph_in_slot(charge_start, data, skip_format=True) - - # Filter to points from now onwards - data = [x for x in data if x["t"] > now] - - # Give up if we have less than 2 points - if len(data) < 2: - return {"start": None, "end": None} - - start_ts, end_ts, _, _ = _next_slot(data, live=True, in_progress=in_progress) - - # These need to be presented with tzinfo or Home Assistant will reject them return { - "start": datetime.utcfromtimestamp(start_ts).replace(tzinfo=pytz.utc) if start_ts else None, - "end": datetime.utcfromtimestamp(end_ts).replace(tzinfo=pytz.utc) if end_ts else None, + "start": start, + "end": end } -def charge_graph_slot_list(charge_start, points, skip_format=False): - """Get list of charge slots from graph points.""" - data = points if skip_format else _format_charge_graph(charge_start, points) - - # Don't return any slots if charge is over - if charge_graph_next_slot(charge_start, points)['end'] is None: - return [] - - data = _sanitise_points(data) - - # Give up if we have less than 2 points - if len(data) < 2: +def slot_list(data): + """Get list of charge slots.""" + session_slots = data['allSessionSlots'] + if session_slots is None or len(session_slots) == 0: return [] - + slots = [] + wh_tally = 0 + + if 'batterySocBefore' in data and data['batterySocBefore'] is not None and data['batterySocBefore']['wh'] is not None: + wh_tally = data['batterySocBefore']['wh'] # Get the wh value we start from - # While we still have data, keep looping - while len(data) > 1: - # Get the next slot - result = _next_slot(data) - - # Break if we fail - if result[0] is None or result[1] is None: - break - - # Append a dict to the slots list with the start and end time + for slot in session_slots: slots.append( { - "start": datetime.utcfromtimestamp(result[0]).replace(tzinfo=pytz.utc).astimezone(), - "end": datetime.utcfromtimestamp(result[1]).replace(tzinfo=pytz.utc).astimezone(), - "charge_in_kwh": -(result[3] / 1000), + "start": datetime.utcfromtimestamp(slot['startTimeMs'] / 1000).replace(tzinfo=pytz.utc, microsecond=0).astimezone(), + "end": datetime.utcfromtimestamp(slot['endTimeMs'] / 1000).replace(tzinfo=pytz.utc, microsecond=0).astimezone(), + "charge_in_kwh": -((slot['estimatedSoc']['wh'] - wh_tally) / 1000), # Work out how much we add in just this slot "source": "smart-charge", "location": None } ) - - # Cut off where we got to in this iteration for next time - data = data[result[2]:] + + wh_tally = slot['estimatedSoc']['wh'] return slots -def charge_graph_in_slot(charge_start, points, skip_format=False): +def in_slot(data): """Are we currently in a charge slot?""" - now = int(time()) - data = points if skip_format else _format_charge_graph(charge_start, points) - - # Loop through every value, skipping the last - for idx in range(0, len(data) - 1): - # This is our current point - if data[idx]["t"] < now and data[idx + 1]["t"] > now: - # If the delta line we are on is steeper than 10, - # we are in a charge slot. - if data[idx + 1]["y"] - data[idx]["y"] > 10: - return True - break + slots = slot_list(data) + # Loop through slots + for slot in slots: + # If we are in one + if slot['start'] < datetime.now().astimezone() and slot['end'] > datetime.now().astimezone(): + return True + return False diff --git a/tests/test_utils.py b/tests/test_utils.py index 4abe3dd..ff9ec9b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,104 +4,3 @@ from time import time from custom_components.ohme import utils - - -async def test_format_charge_graph(hass): - """Test that the _test_format_charge_graph function adds given timestamp / 1000 to each x coordinate.""" - TEST_DATA = [{"x": 10, "y": 0}, {"x": 20, "y": 0}, - {"x": 30, "y": 0}, {"x": 40, "y": 0}] - - start_time = random.randint(1577836800, 1764547200) # 2020-2025 - start_time_ms = start_time * 1000 - - result = utils._format_charge_graph(start_time_ms, TEST_DATA) - expected = [{"t": TEST_DATA[0]['x'] + start_time, "y": mock.ANY}, - {"t": TEST_DATA[1]['x'] + start_time, "y": mock.ANY}, - {"t": TEST_DATA[2]['x'] + start_time, "y": mock.ANY}, - {"t": TEST_DATA[3]['x'] + start_time, "y": mock.ANY}] - - assert expected == result - - -async def test_charge_graph_next_slot(hass): - """Test that we correctly work out when the next slot starts and ends.""" - start_time = int(time()) - TEST_DATA = [{"t": start_time - 100, "y": 0}, - {"t": start_time + 1000, "y": 0}, - {"t": start_time + 1600, "y": 1000}, - {"t": start_time + 1800, "y": 1000}] - - result = utils.charge_graph_next_slot(0, TEST_DATA, skip_format=True) - result = { - "start": result['start'].timestamp(), - "end": result['end'].timestamp(), - } - - expected = { - "start": start_time + 1001, - "end": start_time + 1601, - } - - assert expected == result - - -async def test_charge_graph_in_slot(hass): - """Test that we correctly intepret outselves as in a slot.""" - start_time = int(time()) - TEST_DATA = [{"t": start_time - 100, "y": 0}, - {"t": start_time - 10, "y": 0}, - {"t": start_time + 200, "y": 1000}, - {"t": start_time + 300, "y": 1000}] - - result = utils.charge_graph_in_slot(0, TEST_DATA, skip_format=True) - expected = True - - assert expected == result - - -async def test_next_slot_no_live_no_in_progress(): - """Test that the _next_slot function returns the correct result when live and in_progress are False.""" - TEST_DATA = [{"t": 10, "y": 0}, {"t": 20, "y": 0}, - {"t": 30, "y": 10}, {"t": 40, "y": 20}, - {"t": 50, "y": 20}, {"t": 60, "y": 0}] - - result = utils._next_slot(TEST_DATA, live=False, in_progress=False) - expected = [None, None, 4, 0] - - assert expected == result - - -async def test_next_slot_live_no_in_progress(): - """Test that the _next_slot function returns the correct result when live is True and in_progress is False.""" - TEST_DATA = [{"t": 10, "y": 0}, {"t": 20, "y": 0}, - {"t": 30, "y": 10}, {"t": 40, "y": 20}, - {"t": 50, "y": 20}, {"t": 60, "y": 0}] - - result = utils._next_slot(TEST_DATA, live=True, in_progress=False) - expected = [None, 41, 4, 20] - - assert expected == result - - -async def test_next_slot_no_live_in_progress(): - """Test that the _next_slot function returns the correct result when live is False and in_progress is True.""" - TEST_DATA = [{"t": 10, "y": 0}, {"t": 20, "y": 0}, - {"t": 30, "y": 10}, {"t": 40, "y": 20}, - {"t": 50, "y": 20}, {"t": 60, "y": 0}] - - result = utils._next_slot(TEST_DATA, live=False, in_progress=True) - expected = [None, None, 4, 0] - - assert expected == result - - -async def test_next_slot_live_in_progress(): - """Test that the _next_slot function returns the correct result when live and in_progress are True.""" - TEST_DATA = [{"t": 10, "y": 0}, {"t": 20, "y": 0}, - {"t": 30, "y": 10}, {"t": 40, "y": 20}, - {"t": 50, "y": 20}, {"t": 60, "y": 0}] - - result = utils._next_slot(TEST_DATA, live=True, in_progress=True) - expected = [None, 41, 4, 20] - - assert expected == result