Skip to content
This repository was archived by the owner on Mar 5, 2025. It is now read-only.

Commit 616a93e

Browse files
authored
Add target time and percentage inputs (#25)
* Moved max charge resume code into common api_client * Refactor entity loading * Switch housekeeping * Added target time and percentage inputs
1 parent 0c04e55 commit 616a93e

File tree

6 files changed

+197
-60
lines changed

6 files changed

+197
-60
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ This integration exposes the following entities:
3838
* Car Charging - On when a car is connected and drawing power
3939
* Pending Approval - On when a car is connected and waiting for approval
4040
* Charge Slot Active - On when a charge slot is in progress according to the Ohme-generated charge plan
41-
* Sensors (Charge power) - **These are only available during a charge session**
41+
* Sensors (Charge power) - **Only available during a charge session**
4242
* Power Draw (Watts) - Power draw of connected car
4343
* Current Draw (Amps) - Current draw of connected car
4444
* Voltage (Volts) - Voltage reading
@@ -54,6 +54,9 @@ This integration exposes the following entities:
5454
* Switches (Charge state) - **These are only functional when a car is connected**
5555
* Max Charge - Forces the connected car to charge regardless of set schedule
5656
* Pause Charge - Pauses an ongoing charge
57+
* Inputs - **Only available during a charge session**
58+
* Number: Target Percentage - Change the target percentage of the ongoing charge
59+
* Time: Target Time - Change the time target for the current charge
5760
* Buttons
5861
* Approve Charge - Approves a charge when 'Pending Approval' is on
5962

@@ -67,6 +70,7 @@ The coordinators are listed with their refresh intervals below. Relevant coordin
6770
* Buttons: Approve Charge
6871
* Sensors: Power, current, voltage and next slot (start & end)
6972
* Switches: Max charge, pause charge
73+
* Inputs: Target time and target percentage
7074
* OhmeAccountInfoCoordinator (1m refresh)
7175
* Switches: Lock buttons, require approval and sleep when inactive
7276
* OhmeAdvancedSettingsCoordinator (1m refresh)

custom_components/ohme/__init__.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,11 @@ async def async_setup_entry(hass, entry):
4444
hass.data[DOMAIN][DATA_COORDINATORS] = coordinators
4545

4646
# Create tasks for each entity type
47-
hass.async_create_task(
48-
hass.config_entries.async_forward_entry_setup(entry, "sensor")
49-
)
50-
hass.async_create_task(
51-
hass.config_entries.async_forward_entry_setup(entry, "binary_sensor")
52-
)
53-
hass.async_create_task(
54-
hass.config_entries.async_forward_entry_setup(entry, "switch")
55-
)
56-
hass.async_create_task(
57-
hass.config_entries.async_forward_entry_setup(entry, "button")
58-
)
47+
entity_types = ["sensor", "binary_sensor", "switch", "button", "number", "time"]
48+
for entity_type in entity_types:
49+
hass.async_create_task(
50+
hass.config_entries.async_forward_entry_setup(entry, entity_type)
51+
)
5952

6053
return True
6154

custom_components/ohme/api_client.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from datetime import datetime, timedelta
66
from homeassistant.helpers.entity import DeviceInfo
77
from .const import DOMAIN
8+
from .utils import time_next_occurs
89

910
_LOGGER = logging.getLogger(__name__)
1011

@@ -36,6 +37,9 @@ def __init__(self, email, password):
3637
self._user_id = ""
3738
self._serial = ""
3839

40+
# Cache the last rule to use when we disable max charge or change schedule
41+
self._last_rule = {}
42+
3943
# Sessions
4044
self._session = aiohttp.ClientSession(
4145
base_url="https://api.ohme.io")
@@ -156,7 +160,7 @@ async def _get_request(self, url):
156160
def ct_connected(self):
157161
"""Is CT clamp connected."""
158162
return self._ct_connected
159-
163+
160164
def is_capable(self, capability):
161165
"""Return whether or not this model has a given capability."""
162166
return bool(self._capabilities[capability])
@@ -189,9 +193,33 @@ async def async_max_charge(self):
189193
result = await self._put_request(f"/v1/chargeSessions/{self._serial}/rule?maxCharge=true")
190194
return bool(result)
191195

192-
async def async_apply_charge_rule(self, max_price=False, target_ts=0, target_percent=100, pre_condition=False, pre_condition_length=0):
196+
async def async_apply_charge_rule(self, max_price=None, target_time=None, target_percent=None, pre_condition=None, pre_condition_length=None):
193197
"""Apply charge rule/stop max charge."""
198+
# Check every property. If we've provided it, use that. If not, use the existing.
199+
if max_price is None:
200+
max_price = self._last_rule['settings'][0]['enabled'] if 'settings' in self._last_rule and len(
201+
self._last_rule['settings']) > 1 else False
202+
203+
if target_percent is None:
204+
target_percent = self._last_rule['targetPercent'] if 'targetPercent' in self._last_rule else 80
205+
206+
if pre_condition is None:
207+
pre_condition = self._last_rule['preconditioningEnabled'] if 'preconditioningEnabled' in self._last_rule else False
208+
209+
if pre_condition_length is None:
210+
pre_condition_length = self._last_rule[
211+
'preconditionLengthMins'] if 'preconditionLengthMins' in self._last_rule else 30
194212

213+
if target_time is None:
214+
# Default to 9am
215+
target_time = self._last_rule['targetTime'] if 'targetTime' in self._last_rule else 32400
216+
target_time = (target_time // 3600,
217+
(target_time % 3600) // 60)
218+
219+
target_ts = int(time_next_occurs(
220+
target_time[0], target_time[1]).timestamp() * 1000)
221+
222+
# Convert these to string form
195223
max_price = 'true' if max_price else 'false'
196224
pre_condition = 'true' if pre_condition else 'false'
197225

@@ -209,8 +237,13 @@ async def async_get_charge_sessions(self, is_retry=False):
209237
"""Try to fetch charge sessions endpoint.
210238
If we get a non 200 response, refresh auth token and try again"""
211239
resp = await self._get_request('/v1/chargeSessions')
240+
resp = resp[0]
212241

213-
return resp[0]
242+
# Cache the current rule if we are given it
243+
if resp["mode"] == "SMART_CHARGE" and 'appliedRule' in resp:
244+
self._last_rule = resp["appliedRule"]
245+
246+
return resp
214247

215248
async def async_get_account_info(self):
216249
resp = await self._get_request('/v1/users/me/account')
@@ -249,7 +282,7 @@ async def async_get_charge_statistics(self):
249282
async def async_get_ct_reading(self):
250283
"""Get CT clamp reading."""
251284
resp = await self._get_request(f"/v1/chargeDevices/{self._serial}/advancedSettings")
252-
285+
253286
# If we ever get a reading above 0, assume CT connected
254287
if resp['clampAmps'] and resp['clampAmps'] > 0:
255288
self._ct_connected = True

custom_components/ohme/number.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
import asyncio
3+
from homeassistant.components.number import NumberEntity, NumberDeviceClass
4+
from homeassistant.helpers.entity import generate_entity_id
5+
from homeassistant.core import callback, HomeAssistant
6+
from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, COORDINATOR_ACCOUNTINFO
7+
8+
9+
async def async_setup_entry(
10+
hass: HomeAssistant,
11+
config_entry: config_entries.ConfigEntry,
12+
async_add_entities
13+
):
14+
"""Setup switches and configure coordinator."""
15+
coordinators = hass.data[DOMAIN][DATA_COORDINATORS]
16+
17+
coordinator = coordinators[COORDINATOR_CHARGESESSIONS]
18+
client = hass.data[DOMAIN][DATA_CLIENT]
19+
20+
numbers = [TargetPercentNumber(coordinator, hass, client)]
21+
22+
async_add_entities(numbers, update_before_add=True)
23+
24+
25+
class TargetPercentNumber(NumberEntity):
26+
"""Target percentage sensor."""
27+
_attr_name = "Target Percentage"
28+
_attr_device_class = NumberDeviceClass.BATTERY
29+
_attr_suggested_display_precision = 0
30+
31+
def __init__(self, coordinator, hass: HomeAssistant, client):
32+
self.coordinator = coordinator
33+
34+
self._client = client
35+
36+
self._state = 0
37+
self._last_updated = None
38+
self._attributes = {}
39+
40+
self.entity_id = generate_entity_id(
41+
"number.{}", "ohme_target_percent", hass=hass)
42+
43+
self._attr_device_info = client.get_device_info()
44+
45+
@property
46+
def unique_id(self):
47+
"""The unique ID of the switch."""
48+
return self._client.get_unique_id("target_percent")
49+
50+
async def async_set_native_value(self, value: float) -> None:
51+
"""Update the current value."""
52+
await self._client.async_apply_charge_rule(target_percent=int(value))
53+
54+
await asyncio.sleep(1)
55+
await self.coordinator.async_refresh()
56+
57+
@property
58+
def icon(self):
59+
"""Icon of the sensor."""
60+
return "mdi:battery-heart"
61+
62+
@property
63+
def native_value(self):
64+
"""Get value from data returned from API by coordinator"""
65+
if self.coordinator.data and self.coordinator.data['appliedRule']:
66+
target = round(
67+
self.coordinator.data['appliedRule']['targetPercent'])
68+
69+
if target == 0:
70+
return self._state
71+
72+
self._state = target
73+
return self._state
74+
return None

custom_components/ohme/switch.py

Lines changed: 2 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313

1414
from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, COORDINATOR_ACCOUNTINFO
1515
from .coordinator import OhmeChargeSessionsCoordinator, OhmeAccountInfoCoordinator
16-
from .utils import time_next_occurs
1716

1817
_LOGGER = logging.getLogger(__name__)
1918

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

125-
# Cache the last rule to use when we disable max charge
126-
self._last_rule = {}
127-
128124
self.entity_id = generate_entity_id(
129125
"switch.{}", "ohme_max_charge", hass=hass)
130126

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

152-
# Cache the current rule if we are given it
153-
if self.coordinator.data["mode"] == "SMART_CHARGE" and 'appliedRule' in self.coordinator.data:
154-
self._last_rule = self.coordinator.data["appliedRule"]
155-
156148
self._last_updated = utcnow()
157149

158150
self.async_write_ha_state()
@@ -168,41 +160,8 @@ async def async_turn_on(self):
168160

169161
async def async_turn_off(self):
170162
"""Stop max charging.
171-
We have to provide a full rule to disable max charge, so we try to get as much as possible
172-
from the cached rule, and assume sane defaults if that isn't possible."""
173-
174-
max_price = False
175-
target_ts = 0
176-
target_percent = 80
177-
pre_condition = False,
178-
pre_condition_length = 0
179-
180-
if self._last_rule and 'targetTime' in self._last_rule:
181-
# Convert rule time (seconds from 00:00 to time) to hh:mm
182-
# and find when it next occurs.
183-
next_dt = time_next_occurs(
184-
self._last_rule['targetTime'] // 3600,
185-
(self._last_rule['targetTime'] % 3600) // 60
186-
)
187-
target_ts = int(next_dt.timestamp() * 1000)
188-
else:
189-
next_dt = time_next_occurs(9, 0)
190-
target_ts = int(next_dt.timestamp() * 1000)
191-
192-
if self._last_rule:
193-
max_price = self._last_rule['settings'][0]['enabled'] if 'settings' in self._last_rule and len(
194-
self._last_rule['settings']) > 1 else max_price
195-
target_percent = self._last_rule['targetPercent'] if 'targetPercent' in self._last_rule else target_percent
196-
pre_condition = self._last_rule['preconditioningEnabled'] if 'preconditioningEnabled' in self._last_rule else pre_condition
197-
pre_condition_length = self._last_rule['preconditionLengthMins'] if 'preconditionLengthMins' in self._last_rule else pre_condition_length
198-
199-
await self._client.async_apply_charge_rule(
200-
max_price=max_price,
201-
target_ts=target_ts,
202-
target_percent=target_percent,
203-
pre_condition=pre_condition,
204-
pre_condition_length=pre_condition_length
205-
)
163+
We are not changing anything, just applying the last rule. No need to supply anything."""
164+
await self._client.async_apply_charge_rule()
206165

207166
await asyncio.sleep(1)
208167
await self.coordinator.async_refresh()

custom_components/ohme/time.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
import asyncio
3+
import logging
4+
from homeassistant.components.time import TimeEntity
5+
from homeassistant.helpers.entity import generate_entity_id
6+
from homeassistant.core import callback, HomeAssistant
7+
from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, COORDINATOR_ACCOUNTINFO
8+
from datetime import time as dt_time
9+
10+
_LOGGER = logging.getLogger(__name__)
11+
12+
13+
async def async_setup_entry(
14+
hass: HomeAssistant,
15+
config_entry: config_entries.ConfigEntry,
16+
async_add_entities
17+
):
18+
"""Setup switches and configure coordinator."""
19+
coordinators = hass.data[DOMAIN][DATA_COORDINATORS]
20+
21+
coordinator = coordinators[COORDINATOR_CHARGESESSIONS]
22+
client = hass.data[DOMAIN][DATA_CLIENT]
23+
24+
numbers = [TargetTime(coordinator, hass, client)]
25+
26+
async_add_entities(numbers, update_before_add=True)
27+
28+
29+
class TargetTime(TimeEntity):
30+
"""Target time sensor."""
31+
_attr_name = "Target Time"
32+
33+
def __init__(self, coordinator, hass: HomeAssistant, client):
34+
self.coordinator = coordinator
35+
36+
self._client = client
37+
38+
self._state = 0
39+
self._last_updated = None
40+
self._attributes = {}
41+
42+
self.entity_id = generate_entity_id(
43+
"number.{}", "ohme_target_time", hass=hass)
44+
45+
self._attr_device_info = client.get_device_info()
46+
47+
@property
48+
def unique_id(self):
49+
"""The unique ID of the switch."""
50+
return self._client.get_unique_id("target_time")
51+
52+
async def async_set_value(self, value: dt_time) -> None:
53+
"""Update the current value."""
54+
await self._client.async_apply_charge_rule(target_time=(int(value.hour), int(value.minute)))
55+
56+
await asyncio.sleep(1)
57+
await self.coordinator.async_refresh()
58+
59+
@property
60+
def icon(self):
61+
"""Icon of the sensor."""
62+
return "mdi:alarm-check"
63+
64+
@property
65+
def native_value(self):
66+
"""Get value from data returned from API by coordinator"""
67+
if self.coordinator.data and self.coordinator.data['appliedRule']:
68+
target = self.coordinator.data['appliedRule']['targetTime']
69+
return dt_time(
70+
hour=target // 3600,
71+
minute=(target % 3600) // 60,
72+
second=0
73+
)
74+
return None

0 commit comments

Comments
 (0)