Skip to content

Commit

Permalink
feat: Added sensor to show the current raw intelligent state (1 hour …
Browse files Browse the repository at this point in the history
…dev time) (#1142)
  • Loading branch information
BottlecapDave authored Dec 27, 2024
2 parents b8fed00 + 52cc578 commit 083ed1d
Show file tree
Hide file tree
Showing 12 changed files with 225 additions and 76 deletions.
21 changes: 21 additions & 0 deletions _docs/entities/intelligent.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,27 @@ Each item in `planned_dispatch` or `completed_dispatches` have the following att

You can use the [data_last_retrieved sensor](./diagnostics.md#intelligent-dispatches-data-last-retrieved) to determine when the underlying data was last retrieved from the OE servers.

### Current State

`sensor.octopus_energy_{{ACCOUNT_ID}}_intelligent_state`

This sensor displays the current state of your intelligent provider as told by the OE API. The value of this sensor can be one of the following

* `AUTHENTICATION_PENDING` - ready to start authentication and authorization, or auth is in progress.
* `AUTHENTICATION_FAILED` - failed to connect and ready to restart authentication and authorization.
* `AUTHENTICATION_COMPLETE`- ready to start test (if needed) or pending live where auth or telemetry is delayed.
* `TEST_CHARGE_IN_PROGRESS` - connection and smart control test has successfully started and is occurring.
* `TEST_CHARGE_FAILED` - connection or smart control test has failed or could not start, ready to retry test.
* `TEST_CHARGE_NOT_AVAILABLE` - not currently capable of smart control test (e.g. away from home or unplugged).
* `SETUP_COMPLETE` - test is complete (if needed) and device is live, but not ready for smart control.
* `SMART_CONTROL_CAPABLE` - live and ready for smart control (e.g. at home and plugged in) but none is scheduled.
* `SMART_CONTROL_IN_PROGRESS` - smart control (e.g. smart charging) is scheduled or is currently occurring.
* `BOOSTING` - user has overridden the schedule to immediately boost (e.g. bump charge now).
* `SMART_CONTROL_OFF` - smart control has been (temporarily) disabled (e.g. by the user with holiday mode).
* `SMART_CONTROL_NOT_AVAILABLE` - not currently capable of smart control (e.g. away from home or unplugged).
* `LOST_CONNECTION` - lost connection to the device, ready to re-auth (if not temporary / automatic fix).
* `RETIRED` - / de-authed (re-auth not possible, re-register device to onboard again).

### Bump Charge

`switch.octopus_energy_{{ACCOUNT_ID}}_intelligent_bump_charge`
Expand Down
17 changes: 15 additions & 2 deletions custom_components/octopus_energy/api_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@
}}'''

intelligent_dispatches_query = '''query {{
devices(accountNumber: "{account_id}", deviceId: "{device_id}") {{
id
status {{
currentState
}}
}}
plannedDispatches(accountNumber: "{account_id}") {{
start
end
Expand Down Expand Up @@ -1258,22 +1264,29 @@ async def async_get_gas_standing_charge(self, product_code: str, tariff_code: st
_LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.')
raise TimeoutException()

async def async_get_intelligent_dispatches(self, account_id: str):
async def async_get_intelligent_dispatches(self, account_id: str, device_id: str):
"""Get the user's intelligent dispatches"""
await self.async_refresh_token()

try:
client = self._create_client_session()
url = f'{self._base_url}/v1/graphql/'
# Get account response
payload = { "query": intelligent_dispatches_query.format(account_id=account_id) }
payload = { "query": intelligent_dispatches_query.format(account_id=account_id, device_id=device_id) }
headers = { "Authorization": f"JWT {self._graphql_token}" }
async with client.post(url, json=payload, headers=headers) as response:
response_body = await self.__async_read_response__(response, url)
_LOGGER.debug(f'async_get_intelligent_dispatches: {response_body}')

current_state = None
if (response_body is not None and "data" in response_body and "devices" in response_body["data"]):
for device in response_body["data"]["devices"]:
if device["id"] == device_id:
current_state = device["status"]["currentState"]

if (response_body is not None and "data" in response_body):
return IntelligentDispatches(
current_state,
list(map(lambda ev: IntelligentDispatchItem(
as_utc(parse_datetime(ev["start"])),
as_utc(parse_datetime(ev["end"])),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@ def __init__(
self.location = location

class IntelligentDispatches:
current_state: str | None
planned: list[IntelligentDispatchItem]
completed: list[IntelligentDispatchItem]

def __init__(
self,
current_state: str | None,
planned: list[IntelligentDispatchItem],
completed: list[IntelligentDispatchItem]
):
self.current_state = current_state
self.planned = planned
self.completed = completed
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ async def async_refresh_intelligent_dispatches(
raised_exception = None
if has_intelligent_tariff(current, account_info) and intelligent_device is not None:
try:
dispatches = await client.async_get_intelligent_dispatches(account_id)
dispatches = await client.async_get_intelligent_dispatches(account_id, intelligent_device.id)
_LOGGER.debug(f'Intelligent dispatches retrieved for account {account_id}')
except Exception as e:
if isinstance(e, ApiException) == False:
Expand Down
19 changes: 14 additions & 5 deletions custom_components/octopus_energy/intelligent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
def mock_intelligent_dispatches() -> IntelligentDispatches:
planned: list[IntelligentDispatchItem] = []
completed: list[IntelligentDispatchItem] = []
current_state = "SMART_CONTROL_CAPABLE"

dispatches = [
IntelligentDispatchItem(
Expand Down Expand Up @@ -71,12 +72,18 @@ def mock_intelligent_dispatches() -> IntelligentDispatches:
)

for dispatch in dispatches:
if utcnow() >= dispatch.start and utcnow() <= dispatch.end:
if dispatch.source == INTELLIGENT_SOURCE_SMART_CHARGE:
current_state = "SMART_CONTROL_IN_PROGRESS"
elif dispatch.source == INTELLIGENT_SOURCE_BUMP_CHARGE:
current_state = "BOOSTING"

if (dispatch.end > utcnow()):
planned.append(dispatch)
else:
completed.append(dispatch)

return IntelligentDispatches(planned, completed)
return IntelligentDispatches(current_state, planned, completed)

def mock_intelligent_settings():
return IntelligentSettings(
Expand Down Expand Up @@ -206,13 +213,15 @@ def __init__(self,
charge_limit_supported: bool,
planned_dispatches_supported: bool,
ready_time_supported: bool,
smart_charge_supported: bool):
smart_charge_supported: bool,
current_state_supported: bool):
self.is_default_features = is_default_features
self.bump_charge_supported = bump_charge_supported
self.charge_limit_supported = charge_limit_supported
self.planned_dispatches_supported = planned_dispatches_supported
self.ready_time_supported = ready_time_supported
self.smart_charge_supported = smart_charge_supported
self.current_state_supported = current_state_supported

FULLY_SUPPORTED_INTELLIGENT_PROVIDERS = [
"DAIKIN",
Expand All @@ -237,8 +246,8 @@ def __init__(self,
def get_intelligent_features(provider: str) -> IntelligentFeatures:
normalised_provider = provider.upper() if provider is not None else None
if normalised_provider is not None and normalised_provider in FULLY_SUPPORTED_INTELLIGENT_PROVIDERS:
return IntelligentFeatures(False, True, True, True, True, True)
return IntelligentFeatures(False, True, True, True, True, True, True)
elif normalised_provider == "OHME":
return IntelligentFeatures(False, False, False, False, False, False)
return IntelligentFeatures(False, False, False, False, False, False, False)

return IntelligentFeatures(True, False, False, False, False, False)
return IntelligentFeatures(True, False, False, False, False, False, False)
88 changes: 88 additions & 0 deletions custom_components/octopus_energy/intelligent/current_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import logging

from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import generate_entity_id

from homeassistant.util.dt import (now)
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity
)
from homeassistant.components.sensor import (
RestoreSensor,
)

from .base import OctopusEnergyIntelligentSensor
from ..coordinators.intelligent_dispatches import IntelligentDispatchesCoordinatorResult
from ..utils.attributes import dict_to_typed_dict
from ..api_client.intelligent_device import IntelligentDevice

_LOGGER = logging.getLogger(__name__)

class OctopusEnergyIntelligentCurrentState(CoordinatorEntity, OctopusEnergyIntelligentSensor, RestoreSensor):
"""Sensor for determining the current intelligent state."""

def __init__(self, hass: HomeAssistant, coordinator, device: IntelligentDevice, account_id: str):
"""Init sensor."""

CoordinatorEntity.__init__(self, coordinator)
OctopusEnergyIntelligentSensor.__init__(self, device)

self._account_id = account_id
self._state = None

self.entity_id = generate_entity_id("sensor.{}", self.unique_id, hass=hass)

@property
def unique_id(self):
"""The id of the sensor."""
return f"octopus_energy_{self._account_id}_intelligent_state"

@property
def name(self):
"""Name of the sensor."""
return f"Intelligent State ({self._account_id})"

@property
def icon(self):
"""Icon of the sensor."""
return "mdi:power-plug-battery"

@property
def extra_state_attributes(self):
"""Attributes of the sensor."""
return self._attributes

@property
def native_value(self):
return self._state

@callback
def _handle_coordinator_update(self) -> None:
"""Retrieve the current rate for the sensor."""
# Find the current rate. We only need to do this every half an hour
current = now()
result: IntelligentDispatchesCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None
if (result is not None and result.dispatches is not None):
_LOGGER.debug(f"Updating OctopusEnergyIntelligentCurrentState for '{self._device.id}'")

self._state = result.dispatches.current_state
else:
self._state = None

self._attributes = dict_to_typed_dict(self._attributes)
super()._handle_coordinator_update()

async def async_added_to_hass(self):
"""Call when entity about to be added to hass."""
# If not None, we got an initial value.
await super().async_added_to_hass()
state = await self.async_get_last_state()

if state is not None and self._state is None:
self._state = None if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) else state.state
self._attributes = dict_to_typed_dict(state.attributes, [])
_LOGGER.debug(f'Restored OctopusEnergyIntelligentCurrentState state: {self._state}')
10 changes: 8 additions & 2 deletions custom_components/octopus_energy/sensor.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from datetime import datetime, timedelta
from custom_components.octopus_energy.api_client.intelligent_device import IntelligentDevice
from datetime import timedelta
import voluptuous as vol
import logging

Expand Down Expand Up @@ -57,6 +56,9 @@
from .diagnostics_entities.intelligent_dispatches_data_last_retrieved import OctopusEnergyIntelligentDispatchesDataLastRetrieved
from .diagnostics_entities.intelligent_settings_data_last_retrieved import OctopusEnergyIntelligentSettingsDataLastRetrieved
from .diagnostics_entities.free_electricity_sessions_data_last_retrieved import OctopusEnergyFreeElectricitySessionsDataLastRetrieved
from .api_client.intelligent_device import IntelligentDevice
from .intelligent.current_state import OctopusEnergyIntelligentCurrentState
from .intelligent import get_intelligent_features

from .utils.debug_overrides import async_get_debug_override

Expand Down Expand Up @@ -302,6 +304,10 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent
intelligent_dispatches_coordinator = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES_COORDINATOR] if DATA_INTELLIGENT_DISPATCHES_COORDINATOR in hass.data[DOMAIN][account_id] else None
if intelligent_dispatches_coordinator is not None:
entities.append(OctopusEnergyIntelligentDispatchesDataLastRetrieved(hass, intelligent_dispatches_coordinator, account_id))

intelligent_features = get_intelligent_features(intelligent_device.provider)
if intelligent_features.current_state_supported:
entities.append(OctopusEnergyIntelligentCurrentState(hass, intelligent_dispatches_coordinator, intelligent_device, account_id))

intelligent_settings_coordinator = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_SETTINGS_COORDINATOR] if DATA_INTELLIGENT_SETTINGS_COORDINATOR in hass.data[DOMAIN][account_id] else None
if intelligent_settings_coordinator is not None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from custom_components.octopus_energy.api_client import OctopusEnergyApiClient

@pytest.mark.asyncio
async def test_when_get_intelligent_dispatches_is_called_for_account_on_different_tariff_then_results_are_returned():
async def test_when_get_intelligent_dispatches_is_called_for_account_on_different_tariff_then_exception_is_raised():
# Arrange
context = get_test_context()

Expand All @@ -14,7 +14,7 @@ async def test_when_get_intelligent_dispatches_is_called_for_account_on_differen
# Act
exception_raised = False
try:
await client.async_get_intelligent_dispatches(account_id)
await client.async_get_intelligent_dispatches(account_id, "123")
except:
exception_raised = True

Expand Down
Loading

0 comments on commit 083ed1d

Please sign in to comment.