Skip to content

Commit

Permalink
Doc updates and fixed max charge disable functionality (#19)
Browse files Browse the repository at this point in the history
* Added ePod to README

* Added coordinators info to README

* Shift installation and setup to the top

* Add rule caching so stop max charge works properly
  • Loading branch information
dan-r committed Dec 29, 2023
1 parent abbcabd commit 46fd165
Show file tree
Hide file tree
Showing 4 changed files with 114 additions and 28 deletions.
46 changes: 34 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,29 @@ This is an unofficial integration. I have no affiliation with Ohme besides ownin
This integration does not currently support social login or accounts with multiple chargers. It has been tested with the following hardware:
* Ohme Home Pro [UK]
* Ohme Home/Go [UK]
* Ohme ePod [UK]

If you find any bugs or would like to request a feature, please open an issue.


## Installation

### HACS
This is the recommended installation method.
1. Add this repository to HACS as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories)
2. Search for and install the Ohme addon from HACS
3. Restart Home Assistant

### Manual
1. Download the [latest release](https://github.com/dan-r/HomeAssistant-Ohme/releases)
2. Copy the contents of `custom_components` into the `<config directory>/custom_components` directory of your Home Assistant installation
3. Restart Home Assistant


## Setup
From the Home Assistant Integrations page, search for an add the Ohme integration. If you created your Ohme account through a social login, you will need to 'reset your password' to use this integration.


## Entities
This integration exposes the following entities:

Expand All @@ -33,18 +53,20 @@ This integration exposes the following entities:
* Buttons
* Approve Charge - Approves a charge when 'Pending Approval' is on

## Installation
## Coordinators
Updates are made to entity states by polling the Ohme API. This is handled by 'coordinators' defined to Home Assistant, which refresh at a set interval or when externally triggered.

### HACS
This is the recommended installation method.
1. Add this repository to HACS as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories)
2. Search for and install the Ohme addon from HACS
3. Restart Home Assistant
The coordinators are listed with their refresh intervals below. Relevant coordinators are also refreshed when using switches and buttons.

### Manual
1. Download the [latest release](https://github.com/dan-r/HomeAssistant-Ohme/releases)
2. Copy the contents of `custom_components` into the `<config directory>/custom_components` directory of your Home Assistant installation
3. Restart Home Assistant
* OhmeChargeSessionsCoordinator (30s refresh)
* Binary Sensors: All
* Buttons: Approve Charge
* Sensors: Power, current and next slot
* Switches: Max charge, pause charge
* OhmeAccountInfoCoordinator (1m refresh)
* Switches: Lock buttons, require approval and sleep when inactive
* OhmeAdvancedSettingsCoordinator (1m refresh)
* Sensors: CT reading sensor
* OhmeStatisticsCoordinator (30m refresh)
* Sensors: Accumulative energy usage

## Setup
From the Home Assistant Integrations page, search for an add the Ohme integration. If you created your Ohme account through a social login, you will need to 'reset your password' to use this integration.
35 changes: 23 additions & 12 deletions custom_components/ohme/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,30 @@ def __init__(self, email, password):
if email is None or password is None:
raise Exception("Credentials not provided")

# Credentials from configuration
self._email = email
self._password = password

# Charger and its capabilities
self._device_info = None
self._capabilities = {}

# Authentication
self._token_birth = 0
self._token = None
self._refresh_token = None

# User info
self._user_id = ""
self._serial = ""

# Sessions
self._session = aiohttp.ClientSession(
base_url="https://api.ohme.io")
self._auth_session = aiohttp.ClientSession()


# Auth methods

async def async_create_session(self):
"""Refresh the user auth token from the stored credentials."""
async with self._auth_session.post(
Expand All @@ -54,7 +62,7 @@ async def async_refresh_session(self):
"""Refresh auth token if needed."""
if self._token is None:
return await self.async_create_session()

# Don't refresh token unless its over 45 mins old
if time() - self._token_birth < 2700:
return
Expand All @@ -76,8 +84,8 @@ async def async_refresh_session(self):
self._refresh_token = resp_json['refresh_token']
return True


# Internal methods

def _last_second_of_month_timestamp(self):
"""Get the last second of this month."""
dt = datetime.today()
Expand Down Expand Up @@ -139,20 +147,20 @@ async def _get_request(self, url):

return await resp.json()


# Simple getters

def is_capable(self, capability):
"""Return whether or not this model has a given capability."""
return bool(self._capabilities[capability])

def get_device_info(self):
return self._device_info

def get_unique_id(self, name):
return f"ohme_{self._serial}_{name}"


# Push methods

async def async_pause_charge(self):
"""Pause an ongoing charge"""
result = await self._post_request(f"/v1/chargeSessions/{self._serial}/stop", skip_json=True)
Expand All @@ -173,19 +181,22 @@ async def async_max_charge(self):
result = await self._put_request(f"/v1/chargeSessions/{self._serial}/rule?maxCharge=true")
return bool(result)

async def async_stop_max_charge(self):
"""Stop max charge.
This is more complicated than starting one as we need to give more parameters."""
result = await self._put_request(f"/v1/chargeSessions/{self._serial}/rule?enableMaxPrice=false&toPercent=80.0&inSeconds=43200")
async def async_apply_charge_rule(self, max_price=False, target_ts=0, target_percent=100, pre_condition=False, pre_condition_length=0):
"""Apply charge rule/stop max charge."""

max_price = 'true' if max_price else 'false'
pre_condition = 'true' if pre_condition else 'false'

result = await self._put_request(f"/v1/chargeSessions/{self._serial}/rule?enableMaxPrice={max_price}&targetTs={target_ts}&enablePreconditioning={pre_condition}&toPercent={target_percent}&preconditionLengthMins={pre_condition_length}")
return bool(result)

async def async_set_configuration_value(self, values):
"""Set a configuration value or values."""
result = await self._put_request(f"/v1/chargeDevices/{self._serial}/appSettings", data=values)
return bool(result)


# Pull methods

async def async_get_charge_sessions(self, is_retry=False):
"""Try to fetch charge sessions endpoint.
If we get a non 200 response, refresh auth token and try again"""
Expand Down Expand Up @@ -234,10 +245,10 @@ async def async_get_ct_reading(self):
return resp['clampAmps']



# Exceptions
class ApiException(Exception):
...


class AuthException(ApiException):
...
46 changes: 44 additions & 2 deletions custom_components/ohme/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, COORDINATOR_ACCOUNTINFO
from .coordinator import OhmeChargeSessionsCoordinator, OhmeAccountInfoCoordinator
from .utils import time_next_occurs

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -121,6 +122,9 @@ def __init__(self, coordinator, hass: HomeAssistant, client):
self._last_updated = None
self._attributes = {}

# Cache the last rule to use when we disable max charge
self._last_rule = {}

self.entity_id = generate_entity_id(
"switch.{}", "ohme_max_charge", hass=hass)

Expand All @@ -145,6 +149,10 @@ def _handle_coordinator_update(self) -> None:
self._attr_is_on = bool(
self.coordinator.data["mode"] == "MAX_CHARGE")

# Cache the current rule if we are given it
if self.coordinator.data["mode"] == "SMART_CHARGE" and 'appliedRule' in self.coordinator.data:
self._last_rule = self.coordinator.data["appliedRule"]

self._last_updated = utcnow()

self.async_write_ha_state()
Expand All @@ -159,8 +167,42 @@ async def async_turn_on(self):
await self.coordinator.async_refresh()

async def async_turn_off(self):
"""Turn off the switch."""
await self._client.async_stop_max_charge()
"""Stop max charging.
We have to provide a full rule to disable max charge, so we try to get as much as possible
from the cached rule, and assume sane defaults if that isn't possible."""

max_price = False
target_ts = 0
target_percent = 80
pre_condition = False,
pre_condition_length = 0

if self._last_rule and 'targetTime' in self._last_rule:
# Convert rule time (seconds from 00:00 to time) to hh:mm
# and find when it next occurs.
next_dt = time_next_occurs(
self._last_rule['targetTime'] // 3600,
(self._last_rule['targetTime'] % 3600) // 60
)
target_ts = int(next_dt.timestamp() * 1000)
else:
next_dt = time_next_occurs(9, 0)
target_ts = int(next_dt.timestamp() * 1000)

if self._last_rule:
max_price = self._last_rule['settings'][0]['enabled'] if 'settings' in self._last_rule and len(
self._last_rule['settings']) > 1 else max_price
target_percent = self._last_rule['targetPercent'] if 'targetPercent' in self._last_rule else target_percent
pre_condition = self._last_rule['preconditioningEnabled'] if 'preconditioningEnabled' in self._last_rule else pre_condition
pre_condition_length = self._last_rule['preconditionLengthMins'] if 'preconditionLengthMins' in self._last_rule else pre_condition_length

await self._client.async_apply_charge_rule(
max_price=max_price,
target_ts=target_ts,
target_percent=target_percent,
pre_condition=pre_condition,
pre_condition_length=pre_condition_length
)

await asyncio.sleep(1)
await self.coordinator.async_refresh()
Expand Down
15 changes: 13 additions & 2 deletions custom_components/ohme/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from time import time
from datetime import datetime
from datetime import datetime, timedelta
import pytz


Expand Down Expand Up @@ -29,8 +29,19 @@ def charge_graph_next_slot(charge_start, points):
# 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:
next_ts = data[idx]["t"] + 1 # 1s added here as it otherwise often rounds down to xx:59:59
# 1s added here as it otherwise often rounds down to xx:59:59
next_ts = data[idx]["t"] + 1
break

# This needs to be presented with tzinfo or Home Assistant will reject it
return None if next_ts is None else datetime.utcfromtimestamp(next_ts).replace(tzinfo=pytz.utc)


def time_next_occurs(hour, minute):
"""Find when this time next occurs."""
current = datetime.now()
target = current.replace(hour=hour, minute=minute, second=0, microsecond=0)
while target <= current:
target = target + timedelta(days=1)

return target

0 comments on commit 46fd165

Please sign in to comment.