Skip to content

Commit

Permalink
feat: Added experimental support for Octopus Home Pro (3 hours dev time)
Browse files Browse the repository at this point in the history
  • Loading branch information
BottlecapDave authored Jun 15, 2024
1 parent 951d675 commit 2560b6b
Show file tree
Hide file tree
Showing 24 changed files with 699 additions and 240 deletions.
6 changes: 2 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
node_modules/
**/__pycache__/**
run_tests.sh
run_unit_tests.sh
run_integration_tests.sh
site
run_*tests.sh
site
67 changes: 0 additions & 67 deletions _docs/entities/electricity.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,73 +458,6 @@ The total consumption reported by the meter for all time.
| `is_export` | `boolean` | Determines if the meter exports energy rather than imports |
| `is_smart_meter` | `boolean` | Determines if the meter is considered smart by Octopus Energy |

#### Variants

The following variants of the [Current Accumulative Consumption](#current-accumulative-consumption) are available.

##### Off Peak

`sensor.octopus_energy_electricity_{{METER_SERIAL_NUMBER}}_{{MPAN_NUMBER}}_current_accumulative_consumption_off_peak`

The total consumption reported by the meter for the current day during off peak hours (the lowest available rate).

!!! note
This is only available when on a tariff with 2 or 3 unique rates during a given day.

If you switch to a tariff that meets this criteria, you will need to reload the integration to gain access to this entity.

If you switch to a tariff that no longer meets this criteria, the entity will no longer be updated. When you reload the integration, this entity will no longer be available.

This is [disabled by default](../faq.md#there-are-entities-that-are-disabled-why-are-they-disabled-and-how-do-i-enable-them).

!!! warning
This will only be available if you have specified you have a [Octopus Home Mini](../setup/account.md#home-mini). Do not set unless you have one

!!! info
An export equivalent of this sensor does not exist because the data is not available

##### Standard

`sensor.octopus_energy_electricity_{{METER_SERIAL_NUMBER}}_{{MPAN_NUMBER}}_current_accumulative_consumption_standard`

The total consumption reported by the meter for the current day during standard hours (the middle rate).

!!! note
This is only available when on a tariff with 2 or 3 unique rates during a given day.

If you switch to a tariff that meets this criteria, you will need to reload the integration to gain access to this entity.

If you switch to a tariff that no longer meets this criteria, the entity will no longer be updated. When you reload the integration, this entity will no longer be available.

This is [disabled by default](../faq.md#there-are-entities-that-are-disabled-why-are-they-disabled-and-how-do-i-enable-them).

!!! warning
This will only be available if you have specified you have a [Octopus Home Mini](../setup/account.md#home-mini). Do not set unless you have one

!!! info
An export equivalent of this sensor does not exist because the data is not available

##### Peak

`sensor.octopus_energy_electricity_{{METER_SERIAL_NUMBER}}_{{MPAN_NUMBER}}_current_accumulative_consumption_peak`

The total consumption reported by the meter for the current day during peak hours (the highest available rate).

!!! note
This is only available when on a tariff with 2 or 3 unique rates during a given day.

If you switch to a tariff that meets this criteria, you will need to reload the integration to gain access to this entity.

If you switch to a tariff that no longer meets this criteria, the entity will no longer be updated. When you reload the integration, this entity will no longer be available.

This is [disabled by default](../faq.md#there-are-entities-that-are-disabled-why-are-they-disabled-and-how-do-i-enable-them).

!!! warning
This will only be available if you have specified you have a [Octopus Home Mini](../setup/account.md#home-mini). Do not set unless you have one

!!! info
An export equivalent of this sensor does not exist because the data is not available

### Current Accumulative Cost

`sensor.octopus_energy_electricity_{{METER_SERIAL_NUMBER}}_{{MPAN_NUMBER}}_current_accumulative_cost`
Expand Down
4 changes: 4 additions & 0 deletions _docs/entities/gas.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,10 @@ Each charge item has the following attributes

The total consumption reported by the meter for for all time in m3. This is calculated/estimated using your set [calorific value](../setup/account.md#calorific-value) from the kWh data reported by Octopus Energy.

!!! info

Because this is calculated from your set calorific value across the lifetime of your meter, the value will not be 100% accurate due to calorific values changing over time which cannot be captured.

| Attribute | Type | Description |
|-----------|------|-------------|
| `mprn` | `string` | The mprn for the associated meter |
Expand Down
36 changes: 36 additions & 0 deletions _docs/setup/account.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,39 @@ There has been inconsistencies across tariffs on whether government pricing caps
!!! info

While rates are reflected straight away, consumption based sensors may take up to 24 hours to reflect. This is due to how they look at data and cannot be changed.

## Home Pro

If you are lucky enough to own an [Octopus Home Pro](https://forum.octopus.energy/t/for-the-pro-user/8453/2352/), you can now receive this data locally from within Home Assistant.

!!! warning

Integrating with the Octopus Home Pro is currently experimental. Use at your own risk.

### Prerequisites

The Octopus Home Pro has an internal API which is not currently exposed. In order to make this data available for consumption by this integration you will need to expose a custom API on your device by following the instructions below

1. Follow [the instructions](https://github.com/OctopusSmartEnergy/Home-Pro-SDK-Public/blob/main/Home.md#sdk) to connect to your Octopus Home Pro via SSH
2. Run the command `wget -O setup_ha.sh https://raw.githubusercontent.com/BottlecapDave/HomeAssistant-OctopusEnergy/main/home_pro_server/setup.sh` to download the installation script
3. Run the command `chmod +x setup_ha.sh` to make the script executable
4. Run the command `./setup_ha.sh` to run the installation script
5. Edit `startup.sh` and add the following before the line `# Start the ssh server`

```
export SERVER_AUTH_TOKEN=thisisasecrettoken # Replace with your own unique string
(cd /root/bottlecapdave_homeassistant_octopus_energy && ./start_server.sh)
```

6. Restart your Octopus Home Pro

### Settings

Once the API has been configured, you will need to set the address to the IP address of your Octopus Home Pro followed by the port 8000 (e.g. `http://192.168.1.2:8000`) and the api key to the value you set `SERVER_AUTH_TOKEN` to.

Once configured, the following entities will retrieve data from your Octopus Home Pro at a target rate of every 10 seconds.

* [Electricity - Current Demand](../entities/electricity.md#current-demand)
* [Electricity - Current Total Consumption](../entities/electricity.md#current-total-consumption)
* [Gas - Current Total Consumption kWh](../entities/gas.md#current-total-consumption-kwh)
* [Gas - Current Total Consumption m3](../entities/gas.md#current-total-consumption-m3)
26 changes: 20 additions & 6 deletions custom_components/octopus_energy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
)
from homeassistant.helpers import issue_registry as ir

from .api_client_home_pro import OctopusEnergyHomeProApiClient
from .coordinators.account import AccountCoordinatorResult, async_setup_account_info_coordinator
from .coordinators.intelligent_dispatches import async_setup_intelligent_dispatches_coordinator
from .coordinators.intelligent_settings import async_setup_intelligent_settings_coordinator
Expand All @@ -31,9 +32,11 @@
CONFIG_KIND_TARIFF_COMPARISON,
CONFIG_KIND_COST_TRACKER,
CONFIG_KIND_TARGET_RATE,
CONFIG_MAIN_HOME_PRO_ADDRESS,
CONFIG_MAIN_HOME_PRO_API_KEY,
CONFIG_MAIN_OLD_API_KEY,
CONFIG_VERSION,
DATA_GAS_RATES_COORDINATOR_KEY,
DATA_HOME_PRO_CLIENT,
DATA_INTELLIGENT_DEVICE,
DATA_INTELLIGENT_MPAN,
DATA_INTELLIGENT_SERIAL_NUMBER,
Expand Down Expand Up @@ -100,11 +103,18 @@ async def async_migrate_entry(hass, config_entry):
return True

async def _async_close_client(hass, account_id: str):
if account_id in hass.data[DOMAIN] and DATA_CLIENT in hass.data[DOMAIN][account_id]:
_LOGGER.debug('Closing client...')
client: OctopusEnergyApiClient = hass.data[DOMAIN][account_id][DATA_CLIENT]
await client.async_close()
_LOGGER.debug('Client closed.')
if account_id in hass.data[DOMAIN]:
if DATA_CLIENT in hass.data[DOMAIN][account_id]:
_LOGGER.debug('Closing client...')
client: OctopusEnergyApiClient = hass.data[DOMAIN][account_id][DATA_CLIENT]
await client.async_close()
_LOGGER.debug('Client closed.')

if DATA_HOME_PRO_CLIENT in hass.data[DOMAIN][account_id]:
_LOGGER.debug('Closing home pro client...')
client: OctopusEnergyHomeProApiClient = hass.data[DOMAIN][account_id][DATA_HOME_PRO_CLIENT]
await client.async_close()
_LOGGER.debug('Home pro client closed.')

async def async_setup_entry(hass, entry):
"""This is called from the config flow."""
Expand Down Expand Up @@ -232,6 +242,10 @@ async def async_setup_dependencies(hass, config):
client = OctopusEnergyApiClient(config[CONFIG_MAIN_API_KEY], electricity_price_cap, gas_price_cap)
hass.data[DOMAIN][account_id][DATA_CLIENT] = client

if CONFIG_MAIN_HOME_PRO_ADDRESS in config and CONFIG_MAIN_HOME_PRO_API_KEY in config:
home_pro_client = OctopusEnergyHomeProApiClient(config[CONFIG_MAIN_HOME_PRO_ADDRESS], config[CONFIG_MAIN_HOME_PRO_API_KEY])
hass.data[DOMAIN][account_id][DATA_HOME_PRO_CLIENT] = home_pro_client

# Delete any issues that may have been previously raised
ir.async_delete_issue(hass, DOMAIN, REPAIR_UNIQUE_RATES_CHANGED_KEY.format(account_id))
ir.async_delete_issue(hass, DOMAIN, REPAIR_ACCOUNT_NOT_FOUND.format(account_id))
Expand Down
111 changes: 111 additions & 0 deletions custom_components/octopus_energy/api_client_home_pro/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
from datetime import datetime
import json
import aiohttp
import logging
from threading import RLock

from ..api_client import AuthenticationException, RequestException, ServerException, TimeoutException

_LOGGER = logging.getLogger(__name__)

class OctopusEnergyHomeProApiClient:
_session_lock = RLock()

def __init__(self, base_url: str, api_key: str, timeout_in_seconds = 20):
if (api_key is None):
raise Exception('API KEY is not set')

if (base_url is None):
raise Exception('BaseUrl is not set')

self._api_key = api_key
self._base_url = base_url

self._timeout = aiohttp.ClientTimeout(total=None, sock_connect=timeout_in_seconds, sock_read=timeout_in_seconds)
self._default_headers = {}

self._session = None

async def async_close(self):
with self._session_lock:
await self._session.close()

def _create_client_session(self):
if self._session is not None:
return self._session

with self._session_lock:
self._session = aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers)
return self._session

async def async_ping(self):
try:
client = self._create_client_session()
url = f'{self._base_url}/get_meter_info?meter_type=elec'
headers = { "Authorization": self._api_key }
async with client.get(url, headers=headers) as response:
response_body = await self.__async_read_response__(response, url)
if (response_body is not None and "Status" in response_body):
status: str = response_body["Status"]
return status.lower() == "success"

return False

except TimeoutError:
_LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.')
raise TimeoutException()

async def async_get_consumption(self, is_electricity: bool) -> list | None:
"""Get the latest consumption"""

try:
client = self._create_client_session()
meter_type = 'elec' if is_electricity else 'gas'
url = f'{self._base_url}/get_meter_consumption?meter_type={meter_type}'
headers = { "Authorization": self._api_key }
async with client.get(url, headers=headers) as response:
response_body = await self.__async_read_response__(response, url)
if (response_body is not None and "meter_consump" in response_body and "consum" in response_body["meter_consump"]):
data = response_body["meter_consump"]["consum"]
return [{
"total_consumption": int(data["consumption"]) / 1000,
"demand": int(data["instdmand"]) if "instdmand" in data else 0,
"start": datetime.utcfromtimestamp(int(response_body["meter_consump"]["time"])),
"end": datetime.utcfromtimestamp(int(response_body["meter_consump"]["time"]))
}]

return None

except TimeoutError:
_LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.')
raise TimeoutException()

async def __async_read_response__(self, response, url):
"""Reads the response, logging any json errors"""

text = await response.text()

if response.status >= 400:
if response.status >= 500:
msg = f'DO NOT REPORT - Octopus Energy server error ({url}): {response.status}; {text}'
_LOGGER.warning(msg)
raise ServerException(msg)
elif response.status in [401, 403]:
msg = f'Failed to send request ({url}): {response.status}; {text}'
_LOGGER.warning(msg)
raise AuthenticationException(msg, [])
elif response.status not in [404]:
msg = f'Failed to send request ({url}): {response.status}; {text}'
_LOGGER.warning(msg)
raise RequestException(msg, [])

_LOGGER.info(f"Response {response.status} for '{url}' received")
return None

data_as_json = None
try:
data_as_json = json.loads(text)
except:
raise Exception(f'Failed to extract response json: {url}; {text}')

return data_as_json
21 changes: 20 additions & 1 deletion custom_components/octopus_energy/config/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@
CONFIG_MAIN_OLD_API_KEY,
CONFIG_MAIN_PREVIOUS_ELECTRICITY_CONSUMPTION_DAYS_OFFSET,
CONFIG_MAIN_PREVIOUS_GAS_CONSUMPTION_DAYS_OFFSET,
CONFIG_MAIN_SUPPORTS_LIVE_CONSUMPTION
CONFIG_MAIN_SUPPORTS_LIVE_CONSUMPTION,
CONFIG_MAIN_HOME_PRO_ADDRESS,
CONFIG_MAIN_HOME_PRO_API_KEY
)
from ..api_client import OctopusEnergyApiClient, RequestException, ServerException
from ..api_client_home_pro import OctopusEnergyHomeProApiClient

async def async_migrate_main_config(version: int, data: {}):
new_data = {**data}
Expand Down Expand Up @@ -93,4 +96,20 @@ async def async_validate_main_config(data, account_ids = []):
if data[CONFIG_MAIN_PREVIOUS_GAS_CONSUMPTION_DAYS_OFFSET] < 1:
errors[CONFIG_MAIN_PREVIOUS_GAS_CONSUMPTION_DAYS_OFFSET] = "value_greater_than_zero"

if ((CONFIG_MAIN_HOME_PRO_ADDRESS in data and CONFIG_MAIN_HOME_PRO_API_KEY not in data) or
(CONFIG_MAIN_HOME_PRO_ADDRESS not in data and CONFIG_MAIN_HOME_PRO_API_KEY in data)):
errors[CONFIG_MAIN_HOME_PRO_ADDRESS] = "all_home_pro_values_not_set"

if (CONFIG_MAIN_HOME_PRO_ADDRESS in data and CONFIG_MAIN_HOME_PRO_API_KEY in data):
home_pro_client = OctopusEnergyHomeProApiClient(data[CONFIG_MAIN_HOME_PRO_ADDRESS], data[CONFIG_MAIN_HOME_PRO_API_KEY])

can_connect = False
try:
can_connect = await home_pro_client.async_ping()
except:
can_connect = False

if can_connect == False:
errors[CONFIG_MAIN_HOME_PRO_ADDRESS] = "home_pro_connection_failed"

return errors
6 changes: 6 additions & 0 deletions custom_components/octopus_energy/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from .config.target_rates import merge_target_rate_config, validate_target_rate_config
from .config.main import async_validate_main_config, merge_main_config
from .const import (
CONFIG_MAIN_HOME_PRO_ADDRESS,
CONFIG_MAIN_HOME_PRO_API_KEY,
CONFIG_TARIFF_COMPARISON_MPAN_MPRN,
CONFIG_TARIFF_COMPARISON_NAME,
CONFIG_TARIFF_COMPARISON_PRODUCT_CODE,
Expand Down Expand Up @@ -552,6 +554,8 @@ async def __async_setup_main_schema__(self, config, errors):
vol.Required(CONFIG_MAIN_CALORIFIC_VALUE): cv.positive_float,
vol.Optional(CONFIG_MAIN_ELECTRICITY_PRICE_CAP): cv.positive_float,
vol.Optional(CONFIG_MAIN_GAS_PRICE_CAP): cv.positive_float,
vol.Optional(CONFIG_MAIN_HOME_PRO_ADDRESS): str,
vol.Optional(CONFIG_MAIN_HOME_PRO_API_KEY): str,
}),
{
CONFIG_MAIN_API_KEY: config[CONFIG_MAIN_API_KEY],
Expand All @@ -563,6 +567,8 @@ async def __async_setup_main_schema__(self, config, errors):
CONFIG_MAIN_CALORIFIC_VALUE: calorific_value,
CONFIG_MAIN_ELECTRICITY_PRICE_CAP: config[CONFIG_MAIN_ELECTRICITY_PRICE_CAP] if CONFIG_MAIN_ELECTRICITY_PRICE_CAP in config else None,
CONFIG_MAIN_GAS_PRICE_CAP: config[CONFIG_MAIN_GAS_PRICE_CAP] if CONFIG_MAIN_GAS_PRICE_CAP in config else None,
CONFIG_MAIN_HOME_PRO_ADDRESS: config[CONFIG_MAIN_HOME_PRO_ADDRESS] if CONFIG_MAIN_HOME_PRO_ADDRESS in config else None,
CONFIG_MAIN_HOME_PRO_API_KEY: config[CONFIG_MAIN_HOME_PRO_API_KEY] if CONFIG_MAIN_HOME_PRO_API_KEY in config else None,
}
),
errors=errors
Expand Down
Loading

0 comments on commit 2560b6b

Please sign in to comment.