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

Commit 14cb8b0

Browse files
authored
Updated docs, added charge slot list (#48)
* Updated social login instructions * Added timeout to HTTP requests * Added experimental charge slot sensor * Round charge graph slots * Fix tests...
1 parent f23964d commit 14cb8b0

File tree

6 files changed

+142
-30
lines changed

6 files changed

+142
-30
lines changed

README.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ This is the recommended installation method.
3030
## Setup
3131
From the Home Assistant Integrations page, search for and add the Ohme integration.
3232

33-
If you created your Ohme account through a social login (Apple/Facebook/Google), you will need to set a password in the Ohme app or 'reset your password' to use this integration.
33+
### Social Logins
34+
If you created your Ohme account through an Apple, Facebook or Google account, you will need to set a password in the Ohme app.
35+
36+
Open the menu in the Ohme app (☰) and note the email address shown under your name. This is your login email address and may differ from what you expect.
37+
38+
To set a password, open **Settings** from this menu and click **Account Details > Change Password**. Thanks to [EV Nick](https://www.youtube.com/c/NicolasRaimo) for these instructions.
3439

3540

3641
## Entities
@@ -42,15 +47,16 @@ This integration exposes the following entities:
4247
* Pending Approval - On when a car is connected and waiting for approval
4348
* Charge Slot Active - On when a charge slot is in progress according to the Ohme-generated charge plan
4449
* Charger Online - On if charger is online and connected to the internet
45-
* Sensors (Charge power) - **Only available during a charge session**
50+
* Sensors (Session specific) - **Only available during a charge session**
4651
* Power Draw (Watts) - Power draw of connected car
4752
* Current Draw (Amps) - Current draw of connected car
4853
* Voltage (Volts) - Voltage reading
54+
* Charge Slots - A comma separated list of assigned charge slots
55+
* Next Charge Slot Start - The next time your car will start charging according to the Ohme-generated charge plan
56+
* Next Charge Slot End - The next time your car will stop charging according to the Ohme-generated charge plan
4957
* Sensors (Other)
5058
* CT Reading (Amps) - Reading from attached CT clamp
5159
* Accumulative Energy Usage (kWh) - Total energy used by the charger
52-
* Next Charge Slot Start - The next time your car will start charging according to the Ohme-generated charge plan
53-
* Next Charge Slot End - The next time your car will stop charging according to the Ohme-generated charge plan
5460
* Switches (Settings) - **Only options available to your charger model will show**
5561
* Lock Buttons - Locks buttons on charger
5662
* Require Approval - Require approval to start a charge
@@ -82,7 +88,7 @@ The coordinators are listed with their refresh intervals below. Relevant coordin
8288
* OhmeChargeSessionsCoordinator (30s refresh)
8389
* Binary Sensors: Car connected, car charging, pending approval and charge slot active
8490
* Buttons: Approve Charge
85-
* Sensors: Power, current, voltage and next slot (start & end)
91+
* Sensors: Power, current, voltage, charge slots and next slot (start & end)
8692
* Switches: Max charge, pause charge
8793
* Inputs: Target time, target percentage and preconditioning (If car connected)
8894
* OhmeAccountInfoCoordinator (1m refresh)

custom_components/ohme/api_client.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,10 @@ def __init__(self, email, password):
4141
self._last_rule = {}
4242

4343
# Sessions
44+
timeout = aiohttp.ClientTimeout(total=10)
4445
self._session = aiohttp.ClientSession(
45-
base_url="https://api.ohme.io")
46-
self._auth_session = aiohttp.ClientSession()
46+
base_url="https://api.ohme.io", timeout=timeout)
47+
self._auth_session = aiohttp.ClientSession(timeout=timeout)
4748

4849
# Auth methods
4950

custom_components/ohme/const.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Component constants"""
22
DOMAIN = "ohme"
33
USER_AGENT = "dan-r-homeassistant-ohme"
4-
INTEGRATION_VERSION = "0.4.2"
4+
INTEGRATION_VERSION = "0.4.3"
55
CONFIG_VERSION = 1
66
ENTITY_TYPES = ["sensor", "binary_sensor", "switch", "button", "number", "time"]
77

custom_components/ohme/sensor.py

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Platform for sensor integration."""
22
from __future__ import annotations
3-
3+
from functools import reduce
44
from homeassistant.components.sensor import (
55
SensorDeviceClass,
66
SensorStateClass,
@@ -13,8 +13,7 @@
1313
from homeassistant.util.dt import (utcnow)
1414
from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, COORDINATOR_STATISTICS, COORDINATOR_ADVANCED
1515
from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator, OhmeAdvancedSettingsCoordinator
16-
from .utils import charge_graph_next_slot
17-
16+
from .utils import charge_graph_next_slot, charge_graph_slot_list
1817

1918
async def async_setup_entry(
2019
hass: core.HomeAssistant,
@@ -35,7 +34,8 @@ async def async_setup_entry(
3534
CTSensor(adv_coordinator, hass, client),
3635
EnergyUsageSensor(stats_coordinator, hass, client),
3736
NextSlotEndSensor(coordinator, hass, client),
38-
NextSlotStartSensor(coordinator, hass, client)]
37+
NextSlotStartSensor(coordinator, hass, client),
38+
SlotListSensor(coordinator, hass, client)]
3939

4040
async_add_entities(sensors, update_before_add=True)
4141

@@ -354,3 +354,59 @@ def _handle_coordinator_update(self) -> None:
354354
self._last_updated = utcnow()
355355

356356
self.async_write_ha_state()
357+
358+
359+
class SlotListSensor(CoordinatorEntity[OhmeChargeSessionsCoordinator], SensorEntity):
360+
"""Sensor for next smart charge slot end time."""
361+
_attr_name = "Charge Slots"
362+
363+
def __init__(
364+
self,
365+
coordinator: OhmeChargeSessionsCoordinator,
366+
hass: HomeAssistant,
367+
client):
368+
super().__init__(coordinator=coordinator)
369+
370+
self._state = None
371+
self._attributes = {}
372+
self._last_updated = None
373+
self._client = client
374+
375+
self.entity_id = generate_entity_id(
376+
"sensor.{}", "ohme_charge_slots", hass=hass)
377+
378+
self._attr_device_info = hass.data[DOMAIN][DATA_CLIENT].get_device_info(
379+
)
380+
381+
@property
382+
def unique_id(self) -> str:
383+
"""Return the unique ID of the sensor."""
384+
return self._client.get_unique_id("charge_slots")
385+
386+
@property
387+
def icon(self):
388+
"""Icon of the sensor."""
389+
return "mdi:timetable"
390+
391+
@property
392+
def native_value(self):
393+
"""Return pre-calculated state."""
394+
return self._state
395+
396+
@callback
397+
def _handle_coordinator_update(self) -> None:
398+
"""Get a list of charge slots."""
399+
if self.coordinator.data is None or self.coordinator.data["mode"] == "DISCONNECTED":
400+
self._state = None
401+
else:
402+
slots = charge_graph_slot_list(
403+
self.coordinator.data['startTime'], self.coordinator.data['chargeGraph']['points'])
404+
405+
# Convert list of tuples to text
406+
self._state = reduce(lambda acc, slot: acc + f"{slot[0]}-{slot[1]}, ", slots, "")[:-2]
407+
408+
# Make sure we return None/Unknown if the list is empty
409+
self._state = None if self._state == "" else self._state
410+
411+
self._last_updated = utcnow()
412+
self.async_write_ha_state()

custom_components/ohme/utils.py

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,21 @@
33
from .const import DOMAIN, DATA_OPTIONS
44
import pytz
55

6-
76
def _format_charge_graph(charge_start, points):
87
"""Convert relative time in points array to real timestamp (s)."""
98

10-
charge_start = round(charge_start / 1000)
11-
return [{"t": x["x"] + charge_start, "y": x["y"]} for x in points]
9+
# Add 30s to effectively round all times to the nearest minute
10+
charge_start = round(charge_start / 1000) + 30
1211

12+
# _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]))
1313

14-
def charge_graph_next_slot(charge_start, points, skip_format=False):
15-
"""Get the next charge slot start/end times from a list of graph points."""
16-
now = int(time())
17-
data = points if skip_format else _format_charge_graph(charge_start, points)
14+
return [{"t": x["x"] + charge_start, "y": x["y"]} for x in points]
1815

19-
# Filter to points from now onwards
20-
data = [x for x in data if x["t"] > now]
21-
22-
# Give up if we have less than 2 points
23-
if len(data) < 2:
24-
return {"start": None, "end": None}
2516

17+
def _next_slot(data, live=False):
18+
"""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
19+
start but still want the end of the in progress session, but for the slot list sensor we only want slots that have
20+
a start AND an end."""
2621
start_ts = None
2722
end_ts = None
2823

@@ -38,16 +33,68 @@ def charge_graph_next_slot(charge_start, points, skip_format=False):
3833
start_ts = data[idx]["t"] + 1
3934

4035
# Take the first delta of 0 as the end
41-
if delta == 0 and not end_ts:
36+
if delta == 0 and (start_ts or live) and not end_ts:
4237
end_ts = data[idx]["t"] + 1
4338

39+
if start_ts and end_ts:
40+
break
41+
42+
return [start_ts, end_ts, idx]
43+
44+
45+
def charge_graph_next_slot(charge_start, points, skip_format=False):
46+
"""Get the next charge slot start/end times from a list of graph points."""
47+
now = int(time())
48+
data = points if skip_format else _format_charge_graph(charge_start, points)
49+
50+
# Filter to points from now onwards
51+
data = [x for x in data if x["t"] > now]
52+
53+
# Give up if we have less than 2 points
54+
if len(data) < 2:
55+
return {"start": None, "end": None}
56+
57+
start_ts, end_ts, final_idx = _next_slot(data, live=True)
58+
4459
# These need to be presented with tzinfo or Home Assistant will reject them
4560
return {
4661
"start": datetime.utcfromtimestamp(start_ts).replace(tzinfo=pytz.utc) if start_ts else None,
4762
"end": datetime.utcfromtimestamp(end_ts).replace(tzinfo=pytz.utc) if end_ts else None,
4863
}
4964

5065

66+
def charge_graph_slot_list(charge_start, points, skip_format=False):
67+
"""Get list of charge slots from graph points."""
68+
now = int(time())
69+
data = points if skip_format else _format_charge_graph(charge_start, points)
70+
71+
# Give up if we have less than 2 points
72+
if len(data) < 2:
73+
return []
74+
75+
slots = []
76+
77+
# While we still have data, keep looping
78+
while len(data) > 1:
79+
# Get the next slot
80+
result = _next_slot(data)
81+
82+
# Break if we fail
83+
if result[0] is None:
84+
break
85+
86+
# Append a tuple to the slots list with the start end end time
87+
slots.append((
88+
datetime.fromtimestamp(result[0]).strftime('%H:%M'),
89+
datetime.fromtimestamp(result[1]).strftime('%H:%M'),
90+
))
91+
92+
# Cut off where we got to in this iteration for next time
93+
data = data[result[2]:]
94+
95+
return slots
96+
97+
5198
def charge_graph_in_slot(charge_start, points, skip_format=False):
5299
"""Are we currently in a charge slot?"""
53100
now = int(time())
@@ -75,6 +122,7 @@ def time_next_occurs(hour, minute):
75122

76123
return target
77124

125+
78126
def session_in_progress(hass, data):
79127
"""Is there a session in progress?
80128
Used to check if we should update the current session rather than the first schedule."""
@@ -92,6 +140,7 @@ def session_in_progress(hass, data):
92140

93141
return True
94142

143+
95144
def get_option(hass, option):
96145
"""Return option value, default to False."""
97146
return hass.data[DOMAIN][DATA_OPTIONS].get(option, None)

tests/test_utils.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ async def test_format_charge_graph(hass):
1515
start_time_ms = start_time * 1000
1616

1717
result = utils._format_charge_graph(start_time_ms, TEST_DATA)
18-
expected = [{"t": TEST_DATA[0]['x'] + start_time, "y": mock.ANY},
19-
{"t": TEST_DATA[1]['x'] + start_time, "y": mock.ANY},
20-
{"t": TEST_DATA[2]['x'] + start_time, "y": mock.ANY},
21-
{"t": TEST_DATA[3]['x'] + start_time, "y": mock.ANY}]
18+
expected = [{"t": TEST_DATA[0]['x'] + start_time + 30, "y": mock.ANY},
19+
{"t": TEST_DATA[1]['x'] + start_time + 30, "y": mock.ANY},
20+
{"t": TEST_DATA[2]['x'] + start_time + 30, "y": mock.ANY},
21+
{"t": TEST_DATA[3]['x'] + start_time + 30, "y": mock.ANY}]
2222

2323
assert expected == result
2424

0 commit comments

Comments
 (0)