python-openevse-http 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openevsehttp/__init__.py +58 -0
- openevsehttp/__main__.py +4 -0
- openevsehttp/client.py +473 -0
- openevsehttp/commands.py +511 -0
- openevsehttp/const.py +64 -0
- openevsehttp/exceptions.py +33 -0
- openevsehttp/managers.py +157 -0
- openevsehttp/properties.py +527 -0
- openevsehttp/sensors.py +137 -0
- openevsehttp/utils.py +29 -0
- openevsehttp/websocket.py +275 -0
- python_openevse_http-0.0.0.dist-info/METADATA +121 -0
- python_openevse_http-0.0.0.dist-info/RECORD +16 -0
- python_openevse_http-0.0.0.dist-info/WHEEL +5 -0
- python_openevse_http-0.0.0.dist-info/licenses/LICENSE +201 -0
- python_openevse_http-0.0.0.dist-info/top_level.txt +1 -0
openevsehttp/sensors.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Sensor data posting methods for the OpenEVSE charger."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .const import BAT_LVL, BAT_RANGE, GRID, SOLAR, TTF, VOLTAGE
|
|
10
|
+
from .exceptions import UnsupportedFeature
|
|
11
|
+
|
|
12
|
+
_LOGGER = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SensorsMixin:
|
|
16
|
+
"""Mixin providing sensor data posting methods for OpenEVSE."""
|
|
17
|
+
|
|
18
|
+
url: str
|
|
19
|
+
|
|
20
|
+
# These are defined in client.py
|
|
21
|
+
def _version_check(self, min_version: str, max_version: str = "") -> bool:
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
|
|
24
|
+
async def process_request(
|
|
25
|
+
self, url: str, method: str = "", data: Any = None, rapi: Any = None
|
|
26
|
+
) -> Mapping[str, Any] | list[Any] | str:
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
|
|
29
|
+
def _normalize_response(self, response: Any) -> dict[str, Any] | list[Any]:
|
|
30
|
+
"""Normalize response to a dict or list."""
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
|
|
33
|
+
# HTTP Posting of grid voltage
|
|
34
|
+
async def grid_voltage(self, voltage: int | None = None) -> None:
|
|
35
|
+
"""Send pushed sensor data to grid voltage."""
|
|
36
|
+
if not self._version_check("2.9.1"):
|
|
37
|
+
_LOGGER.debug("Feature not supported for older firmware.")
|
|
38
|
+
raise UnsupportedFeature
|
|
39
|
+
|
|
40
|
+
url = f"{self.url}status"
|
|
41
|
+
data = {}
|
|
42
|
+
|
|
43
|
+
if voltage is not None:
|
|
44
|
+
data[VOLTAGE] = voltage
|
|
45
|
+
|
|
46
|
+
if not data:
|
|
47
|
+
_LOGGER.info("No sensor data to send to device.")
|
|
48
|
+
else:
|
|
49
|
+
_LOGGER.debug("Posting voltage: %s", data)
|
|
50
|
+
response = await self.process_request(url=url, method="post", data=data)
|
|
51
|
+
_LOGGER.debug(
|
|
52
|
+
"Voltage posting response: %s", self._normalize_response(response)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Self production HTTP Posting
|
|
56
|
+
async def self_production(
|
|
57
|
+
self,
|
|
58
|
+
grid: int | None = None,
|
|
59
|
+
solar: int | None = None,
|
|
60
|
+
invert: bool = True,
|
|
61
|
+
voltage: int | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Send pushed sensor data to self-production."""
|
|
64
|
+
if not self._version_check("2.9.1"):
|
|
65
|
+
_LOGGER.debug("Feature not supported for older firmware.")
|
|
66
|
+
raise UnsupportedFeature
|
|
67
|
+
|
|
68
|
+
# Invert the sensor -import/+export
|
|
69
|
+
if invert and grid is not None:
|
|
70
|
+
grid = grid * -1
|
|
71
|
+
|
|
72
|
+
url = f"{self.url}status"
|
|
73
|
+
data = {}
|
|
74
|
+
|
|
75
|
+
# Prefer grid sensor data
|
|
76
|
+
if grid is not None:
|
|
77
|
+
data[GRID] = grid
|
|
78
|
+
elif solar is not None:
|
|
79
|
+
data[SOLAR] = solar
|
|
80
|
+
if voltage is not None:
|
|
81
|
+
data[VOLTAGE] = voltage
|
|
82
|
+
|
|
83
|
+
if not data:
|
|
84
|
+
_LOGGER.info("No sensor data to send to device.")
|
|
85
|
+
else:
|
|
86
|
+
_LOGGER.debug("Posting self-production: %s", data)
|
|
87
|
+
response = await self.process_request(url=url, method="post", data=data)
|
|
88
|
+
_LOGGER.debug(
|
|
89
|
+
"Self-production response: %s", self._normalize_response(response)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# State of charge HTTP posting
|
|
93
|
+
async def soc(
|
|
94
|
+
self,
|
|
95
|
+
battery_level: int | None = None,
|
|
96
|
+
battery_range: int | None = None,
|
|
97
|
+
time_to_full: int | None = None,
|
|
98
|
+
voltage: int | None = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Send pushed sensor data to State of Charge."""
|
|
101
|
+
if not self._version_check("4.1.0"):
|
|
102
|
+
_LOGGER.debug("Feature not supported for older firmware.")
|
|
103
|
+
raise UnsupportedFeature
|
|
104
|
+
|
|
105
|
+
url = f"{self.url}status"
|
|
106
|
+
data = {}
|
|
107
|
+
|
|
108
|
+
# Build post data
|
|
109
|
+
if battery_level is not None:
|
|
110
|
+
data[BAT_LVL] = battery_level
|
|
111
|
+
if battery_range is not None:
|
|
112
|
+
data[BAT_RANGE] = battery_range
|
|
113
|
+
if time_to_full is not None:
|
|
114
|
+
data[TTF] = time_to_full
|
|
115
|
+
if voltage is not None:
|
|
116
|
+
data[VOLTAGE] = voltage
|
|
117
|
+
|
|
118
|
+
if not data:
|
|
119
|
+
_LOGGER.info("No SOC data to send to device.")
|
|
120
|
+
else:
|
|
121
|
+
_LOGGER.debug("Posting SOC data: %s", data)
|
|
122
|
+
response = await self.process_request(url=url, method="post", data=data)
|
|
123
|
+
_LOGGER.debug("SOC response: %s", self._normalize_response(response))
|
|
124
|
+
|
|
125
|
+
# Shaper HTTP Posting
|
|
126
|
+
async def set_shaper_live_pwr(self, power: int) -> None:
|
|
127
|
+
"""Send pushed sensor data to shaper."""
|
|
128
|
+
if not self._version_check("4.0.0"):
|
|
129
|
+
_LOGGER.debug("Feature not supported for older firmware.")
|
|
130
|
+
raise UnsupportedFeature
|
|
131
|
+
|
|
132
|
+
url = f"{self.url}status"
|
|
133
|
+
data = {"shaper_live_pwr": power}
|
|
134
|
+
|
|
135
|
+
_LOGGER.debug("Posting shaper data: %s", data)
|
|
136
|
+
response = await self.process_request(url=url, method="post", data=data)
|
|
137
|
+
_LOGGER.debug("Shaper response: %s", self._normalize_response(response))
|
openevsehttp/utils.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Utility functions for python-openevse-http."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from awesomeversion import AwesomeVersion
|
|
7
|
+
|
|
8
|
+
_LOGGER = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize_version(version: str) -> str:
|
|
12
|
+
"""Normalize the version string to strip 'dev' tag."""
|
|
13
|
+
if "dev" in version:
|
|
14
|
+
_LOGGER.debug("Stripping 'dev' from version.")
|
|
15
|
+
value = version.split(".")
|
|
16
|
+
return ".".join(value[0:3])
|
|
17
|
+
return version
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_awesome_version(version: str) -> AwesomeVersion:
|
|
21
|
+
"""Parse and normalize the version string, returning an AwesomeVersion."""
|
|
22
|
+
if "master" in version:
|
|
23
|
+
version = "dev"
|
|
24
|
+
value = normalize_version(version)
|
|
25
|
+
if "dev" not in version:
|
|
26
|
+
firmware_search = re.search(r"\d+\.\d+\.\d+", value)
|
|
27
|
+
if firmware_search:
|
|
28
|
+
value = firmware_search.group(0)
|
|
29
|
+
return AwesomeVersion(value)
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Websocket class for OpenEVSE HTTP."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import datetime
|
|
5
|
+
import inspect
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
|
|
10
|
+
_LOGGER = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
MAX_FAILED_ATTEMPTS = 5
|
|
13
|
+
|
|
14
|
+
ERROR_AUTH_FAILURE = "Authorization failure"
|
|
15
|
+
ERROR_TOO_MANY_RETRIES = "Too many retries"
|
|
16
|
+
ERROR_UNKNOWN = "Unknown"
|
|
17
|
+
ERROR_PING_TIMEOUT = "No pong reply"
|
|
18
|
+
|
|
19
|
+
SIGNAL_CONNECTION_STATE = "websocket_state"
|
|
20
|
+
STATE_CONNECTED = "connected"
|
|
21
|
+
STATE_DISCONNECTED = "disconnected"
|
|
22
|
+
STATE_STARTING = "starting"
|
|
23
|
+
STATE_STOPPED = "stopped"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OpenEVSEWebsocket:
|
|
27
|
+
"""Represent a websocket connection to a OpenEVSE charger."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
server,
|
|
32
|
+
callback,
|
|
33
|
+
user=None,
|
|
34
|
+
password=None,
|
|
35
|
+
session: aiohttp.ClientSession | None = None,
|
|
36
|
+
):
|
|
37
|
+
"""Initialize a OpenEVSEWebsocket instance."""
|
|
38
|
+
self.session = session
|
|
39
|
+
self._session_external = session is not None
|
|
40
|
+
self.uri = self._get_uri(server)
|
|
41
|
+
self._user = user
|
|
42
|
+
self._password = password
|
|
43
|
+
self.callback = callback
|
|
44
|
+
self._state = STATE_DISCONNECTED
|
|
45
|
+
self.failed_attempts = 0
|
|
46
|
+
self._error_reason = None
|
|
47
|
+
self._client = None
|
|
48
|
+
self._ping = None
|
|
49
|
+
self._pong = None
|
|
50
|
+
self._tasks: set[asyncio.Task] = set()
|
|
51
|
+
self._listener_loop: asyncio.AbstractEventLoop | None = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def state(self):
|
|
55
|
+
"""Return the current state."""
|
|
56
|
+
return self._state
|
|
57
|
+
|
|
58
|
+
@state.setter
|
|
59
|
+
def state(self, value):
|
|
60
|
+
"""Setter that schedules the callback."""
|
|
61
|
+
self._state = value
|
|
62
|
+
_LOGGER.debug("Websocket %s", value)
|
|
63
|
+
|
|
64
|
+
if not self.callback:
|
|
65
|
+
self._error_reason = None
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
# Prepare the coroutine or invoke the callback
|
|
69
|
+
coro = self.callback(SIGNAL_CONNECTION_STATE, value, self._error_reason)
|
|
70
|
+
|
|
71
|
+
if not inspect.isawaitable(coro):
|
|
72
|
+
self._error_reason = None
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
if self._listener_loop:
|
|
77
|
+
self._listener_loop.call_soon_threadsafe(self._schedule_task, coro)
|
|
78
|
+
else:
|
|
79
|
+
try:
|
|
80
|
+
task = asyncio.ensure_future(coro)
|
|
81
|
+
self._tasks.add(task)
|
|
82
|
+
task.add_done_callback(self._tasks.discard)
|
|
83
|
+
except RuntimeError:
|
|
84
|
+
# Fallback to get_event_loop if ensure_future fails and no _listener_loop
|
|
85
|
+
loop = asyncio.get_event_loop()
|
|
86
|
+
loop.call_soon_threadsafe(self._schedule_task, coro)
|
|
87
|
+
except RuntimeError:
|
|
88
|
+
_LOGGER.error("Failed to schedule callback from sync context: %s", coro)
|
|
89
|
+
if hasattr(coro, "close"):
|
|
90
|
+
coro.close()
|
|
91
|
+
self._error_reason = None
|
|
92
|
+
|
|
93
|
+
def _schedule_task(self, coro):
|
|
94
|
+
"""Schedule a task from a thread-safe context."""
|
|
95
|
+
try:
|
|
96
|
+
task = asyncio.ensure_future(coro)
|
|
97
|
+
self._tasks.add(task)
|
|
98
|
+
task.add_done_callback(self._tasks.discard)
|
|
99
|
+
except RuntimeError:
|
|
100
|
+
_LOGGER.error("Failed to schedule callback task: %s", coro)
|
|
101
|
+
# If we still can't schedule it, we must at least close the coroutine
|
|
102
|
+
# to avoid RuntimeWarning: coroutine '...' was never awaited
|
|
103
|
+
if hasattr(coro, "close"):
|
|
104
|
+
coro.close()
|
|
105
|
+
|
|
106
|
+
async def _set_state(self, value):
|
|
107
|
+
"""Async helper to set the state and await the callback."""
|
|
108
|
+
self._state = value
|
|
109
|
+
_LOGGER.debug("Websocket %s", value)
|
|
110
|
+
if self.callback:
|
|
111
|
+
result = self.callback(SIGNAL_CONNECTION_STATE, value, self._error_reason)
|
|
112
|
+
if inspect.isawaitable(result):
|
|
113
|
+
await result
|
|
114
|
+
self._error_reason = None
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _get_uri(server):
|
|
118
|
+
"""Generate the websocket URI."""
|
|
119
|
+
return server[: server.rfind("/")].replace("http", "ws") + "/ws"
|
|
120
|
+
|
|
121
|
+
async def running(self):
|
|
122
|
+
"""Open a persistent websocket connection and act on events."""
|
|
123
|
+
await self._ensure_session()
|
|
124
|
+
await self._set_state(STATE_STARTING)
|
|
125
|
+
auth = None
|
|
126
|
+
|
|
127
|
+
if self._user and self._password:
|
|
128
|
+
auth = aiohttp.BasicAuth(self._user, self._password)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
async with self.session.ws_connect(
|
|
132
|
+
self.uri,
|
|
133
|
+
heartbeat=15,
|
|
134
|
+
auth=auth,
|
|
135
|
+
) as ws_client:
|
|
136
|
+
self._client = ws_client
|
|
137
|
+
await self._set_state(STATE_CONNECTED)
|
|
138
|
+
self.failed_attempts = 0
|
|
139
|
+
await self._handle_messages(ws_client)
|
|
140
|
+
|
|
141
|
+
except aiohttp.ClientResponseError as error:
|
|
142
|
+
await self._handle_response_error(error)
|
|
143
|
+
except (aiohttp.ClientConnectionError, asyncio.TimeoutError) as error:
|
|
144
|
+
await self._handle_connection_error(error)
|
|
145
|
+
except Exception as error: # pylint: disable=broad-except
|
|
146
|
+
if self.state != STATE_STOPPED:
|
|
147
|
+
_LOGGER.exception("Unexpected exception occurred: %s", error)
|
|
148
|
+
self._error_reason = error
|
|
149
|
+
await self._set_state(STATE_STOPPED)
|
|
150
|
+
else:
|
|
151
|
+
if self.state != STATE_STOPPED:
|
|
152
|
+
await self._set_state(STATE_DISCONNECTED)
|
|
153
|
+
await asyncio.sleep(5)
|
|
154
|
+
finally:
|
|
155
|
+
if self._client is not None:
|
|
156
|
+
await self._client.close()
|
|
157
|
+
self._client = None
|
|
158
|
+
|
|
159
|
+
async def _handle_messages(self, ws_client):
|
|
160
|
+
"""Handle incoming websocket messages."""
|
|
161
|
+
async for message in ws_client:
|
|
162
|
+
if self.state == STATE_STOPPED:
|
|
163
|
+
break
|
|
164
|
+
|
|
165
|
+
if message.type == aiohttp.WSMsgType.TEXT:
|
|
166
|
+
msg = message.json()
|
|
167
|
+
if isinstance(msg, dict) and "pong" in msg:
|
|
168
|
+
self._pong = datetime.datetime.now()
|
|
169
|
+
if len(msg) == 1:
|
|
170
|
+
# Pure pong frame, skip callback
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
msgtype = "data"
|
|
174
|
+
if self.callback:
|
|
175
|
+
result = self.callback(msgtype, msg, None)
|
|
176
|
+
if inspect.isawaitable(result):
|
|
177
|
+
await result
|
|
178
|
+
|
|
179
|
+
elif message.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
|
180
|
+
if message.type == aiohttp.WSMsgType.CLOSED:
|
|
181
|
+
_LOGGER.warning("Websocket connection closed")
|
|
182
|
+
else:
|
|
183
|
+
_LOGGER.error("Websocket error")
|
|
184
|
+
break
|
|
185
|
+
|
|
186
|
+
async def _handle_response_error(self, error):
|
|
187
|
+
"""Handle ClientResponseError."""
|
|
188
|
+
if error.status == 401:
|
|
189
|
+
_LOGGER.error("Credentials rejected: %s", error)
|
|
190
|
+
self._error_reason = ERROR_AUTH_FAILURE
|
|
191
|
+
else:
|
|
192
|
+
_LOGGER.error("Unexpected response received: %s", error)
|
|
193
|
+
self._error_reason = error
|
|
194
|
+
await self._set_state(STATE_STOPPED)
|
|
195
|
+
|
|
196
|
+
async def _handle_connection_error(self, error):
|
|
197
|
+
"""Handle connection errors."""
|
|
198
|
+
self.failed_attempts += 1
|
|
199
|
+
if self.failed_attempts > MAX_FAILED_ATTEMPTS:
|
|
200
|
+
self._error_reason = ERROR_TOO_MANY_RETRIES
|
|
201
|
+
await self._set_state(STATE_STOPPED)
|
|
202
|
+
elif self.state != STATE_STOPPED:
|
|
203
|
+
retry_delay = min(2 ** (self.failed_attempts - 1) * 30, 300)
|
|
204
|
+
_LOGGER.error(
|
|
205
|
+
"Websocket connection failed, retrying in %ds: %s",
|
|
206
|
+
retry_delay,
|
|
207
|
+
error,
|
|
208
|
+
)
|
|
209
|
+
await self._set_state(STATE_DISCONNECTED)
|
|
210
|
+
await asyncio.sleep(retry_delay)
|
|
211
|
+
|
|
212
|
+
async def listen(self):
|
|
213
|
+
"""Start the listening websocket."""
|
|
214
|
+
await self._ensure_session()
|
|
215
|
+
self.failed_attempts = 0
|
|
216
|
+
self._listener_loop = asyncio.get_running_loop()
|
|
217
|
+
try:
|
|
218
|
+
while self.state != STATE_STOPPED:
|
|
219
|
+
await self.running()
|
|
220
|
+
finally:
|
|
221
|
+
self._listener_loop = None
|
|
222
|
+
|
|
223
|
+
async def _ensure_session(self):
|
|
224
|
+
"""Ensure aiohttp.ClientSession exists."""
|
|
225
|
+
if self.session is None:
|
|
226
|
+
self.session = aiohttp.ClientSession()
|
|
227
|
+
self._session_external = False
|
|
228
|
+
|
|
229
|
+
async def close(self):
|
|
230
|
+
"""Close the listening websocket."""
|
|
231
|
+
await self._set_state(STATE_STOPPED)
|
|
232
|
+
|
|
233
|
+
if self._tasks:
|
|
234
|
+
for task in self._tasks:
|
|
235
|
+
task.cancel()
|
|
236
|
+
await asyncio.gather(*self._tasks, return_exceptions=True)
|
|
237
|
+
self._tasks.clear()
|
|
238
|
+
|
|
239
|
+
if self._client is not None:
|
|
240
|
+
await self._client.close()
|
|
241
|
+
self._client = None
|
|
242
|
+
# Only close the session if we created it
|
|
243
|
+
if not self._session_external and self.session is not None:
|
|
244
|
+
await self.session.close()
|
|
245
|
+
self.session = None
|
|
246
|
+
|
|
247
|
+
async def keepalive(self):
|
|
248
|
+
"""Send ping requests to websocket."""
|
|
249
|
+
if self._ping and self._pong:
|
|
250
|
+
time_delta = self._pong - self._ping
|
|
251
|
+
if time_delta < datetime.timedelta(0):
|
|
252
|
+
# Negative time should indicate no pong reply so consider the
|
|
253
|
+
# websocket disconnected.
|
|
254
|
+
self._error_reason = ERROR_PING_TIMEOUT
|
|
255
|
+
await self._set_state(STATE_DISCONNECTED)
|
|
256
|
+
|
|
257
|
+
data = {"ping": 1}
|
|
258
|
+
_LOGGER.debug("Sending message: %s to websocket.", data)
|
|
259
|
+
try:
|
|
260
|
+
if self._client:
|
|
261
|
+
await self._client.send_json(data)
|
|
262
|
+
self._ping = datetime.datetime.now()
|
|
263
|
+
_LOGGER.debug("Ping message sent.")
|
|
264
|
+
else:
|
|
265
|
+
_LOGGER.warning("Websocket client not found.")
|
|
266
|
+
except TypeError as err:
|
|
267
|
+
_LOGGER.error("Attempt to send ping data failed: %s", err)
|
|
268
|
+
except ValueError as err:
|
|
269
|
+
_LOGGER.error("Error parsing data: %s", err)
|
|
270
|
+
except RuntimeError as err:
|
|
271
|
+
_LOGGER.debug("Websocket connection issue: %s", err)
|
|
272
|
+
await self._set_state(STATE_DISCONNECTED)
|
|
273
|
+
except Exception as err: # pylint: disable=broad-exception-caught
|
|
274
|
+
_LOGGER.debug("Problem sending ping request: %s", err)
|
|
275
|
+
await self._set_state(STATE_DISCONNECTED)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python_openevse_http
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Python wrapper for OpenEVSE HTTP API
|
|
5
|
+
Home-page: https://github.com/firstof9/python-openevse-http
|
|
6
|
+
Download-URL: https://github.com/firstof9/python-openevse-http
|
|
7
|
+
Author: firstof9
|
|
8
|
+
Author-email: firstof9@gmail.com
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Natural Language :: English
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
17
|
+
Requires-Python: >=3.13
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: aiohttp
|
|
21
|
+
Dynamic: author
|
|
22
|
+
Dynamic: author-email
|
|
23
|
+
Dynamic: classifier
|
|
24
|
+
Dynamic: description
|
|
25
|
+
Dynamic: description-content-type
|
|
26
|
+
Dynamic: download-url
|
|
27
|
+
Dynamic: home-page
|
|
28
|
+
Dynamic: license
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
Dynamic: requires-dist
|
|
31
|
+
Dynamic: requires-python
|
|
32
|
+
Dynamic: summary
|
|
33
|
+
|
|
34
|
+

|
|
35
|
+

|
|
36
|
+

|
|
37
|
+

|
|
38
|
+
|
|
39
|
+
# python-openevse-http
|
|
40
|
+
|
|
41
|
+
A Python library for communicating with [OpenEVSE](https://www.openevse.com/) chargers via the HTTP API on ESP8266 and ESP32-based WiFi modules.
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
- **Asynchronous**: Built on `aiohttp` for non-blocking I/O.
|
|
46
|
+
- **WebSocket Support**: Real-time updates for charger status.
|
|
47
|
+
- **Firmware Support**: Compatible with ESP8266 (2.x) and ESP32 (4.x+) WiFi firmware.
|
|
48
|
+
- **Comprehensive API**:
|
|
49
|
+
- Query status and configuration.
|
|
50
|
+
- Manage manual overrides.
|
|
51
|
+
- Control charging claims and limits.
|
|
52
|
+
- Handle schedules.
|
|
53
|
+
- **Shaper Toggle**: Enable or disable the grid shaper feature (requires firmware 4.0.0+).
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install python_openevse_http
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Quick Start
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import asyncio
|
|
65
|
+
from openevsehttp import OpenEVSE
|
|
66
|
+
|
|
67
|
+
async def main():
|
|
68
|
+
# Initialize the charger
|
|
69
|
+
charger = OpenEVSE("192.168.1.30")
|
|
70
|
+
|
|
71
|
+
# Update state
|
|
72
|
+
await charger.update()
|
|
73
|
+
|
|
74
|
+
print(f"Charger State: {charger.status}")
|
|
75
|
+
print(f"Current Charge: {charger.charge_current}A")
|
|
76
|
+
|
|
77
|
+
# Toggle the Shaper feature
|
|
78
|
+
if charger.shaper_active:
|
|
79
|
+
print("Shaper is active, disabling...")
|
|
80
|
+
else:
|
|
81
|
+
print("Shaper is inactive, enabling...")
|
|
82
|
+
|
|
83
|
+
await charger.toggle_shaper()
|
|
84
|
+
|
|
85
|
+
# Clean up
|
|
86
|
+
await charger.close()
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
asyncio.run(main())
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## API Support Matrix
|
|
93
|
+
|
|
94
|
+
| Endpoint | Methods | Supported | Description |
|
|
95
|
+
| :--- | :--- | :---: | :--- |
|
|
96
|
+
| `/status` | GET, POST | ✅ | Real-time status, sensors, and **Vehicle SoC** pushing |
|
|
97
|
+
| `/config` | GET, POST | ✅ | System and WiFi configuration |
|
|
98
|
+
| `/override` | GET, POST, PATCH, DELETE | ✅ | Manual charging overrides & current limits |
|
|
99
|
+
| `/claims` | GET, POST, DELETE | ✅ | Client-based charging claims |
|
|
100
|
+
| `/schedule` | GET, POST | ✅ | Charging schedule management |
|
|
101
|
+
| `/limit` | GET, POST, DELETE | ✅ | Charge limits (Time, Energy, SoC) |
|
|
102
|
+
| `/shaper` | POST | ✅ | Grid shaper control (v4.0.0+) |
|
|
103
|
+
| `/restart` | POST | ✅ | Reboot WiFi gateway or EVSE module |
|
|
104
|
+
| `/divertmode` | POST | ✅ | Solar divert mode control |
|
|
105
|
+
| `/r` (RAPI) | POST | ✅ | Direct RAPI command interface |
|
|
106
|
+
| `/ws` | GET | ✅ | WebSocket real-time updates |
|
|
107
|
+
| `/time` | GET, POST | ❌ | RTC and NTP time settings |
|
|
108
|
+
| `/logs` | GET | ❌ | System and debug event logs |
|
|
109
|
+
| `/emeter` | DELETE | ❌ | Energy meter reset |
|
|
110
|
+
| `/wifi` | GET, POST | ❌ | Network scanning and AP configuration |
|
|
111
|
+
| `/tesla` | GET | ❌ | Tesla vehicle integration |
|
|
112
|
+
| `/certificates`| GET, POST, DELETE | ❌ | SSL/TLS certificate management |
|
|
113
|
+
| `/schedule/plan`| GET | ❌ | Schedule planning and optimization |
|
|
114
|
+
| `/update` | POST | ❌ | Firmware update interface |
|
|
115
|
+
| `/rfid/add` | POST | ❌ | RFID tag management |
|
|
116
|
+
|
|
117
|
+
✅ = Fully Supported \| ⚠️ = Partial Support \| ❌ = Not yet implemented
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
This project is licensed under the Apache-2.0 License.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
openevsehttp/__init__.py,sha256=I6a1mjOZHYiWb_qfCuDuFLOOncrkkB_7uwybtOIujfY,1165
|
|
2
|
+
openevsehttp/__main__.py,sha256=EHmSdT7GjAVvHQxvLBTjZXsj_V5SB6B2_kpgUAT7mPM,146
|
|
3
|
+
openevsehttp/client.py,sha256=JBAC1jJGdzOabVAqHv8x6EJDVjhaW4t7Iwqn4lDWhwE,17502
|
|
4
|
+
openevsehttp/commands.py,sha256=lANhgVhtJJlQxLwFlMrxk3DrmnY2bXK5h4z3o9o6ZEk,20617
|
|
5
|
+
openevsehttp/const.py,sha256=y-2hGv_PCal_-VCSGC7IIyzQYtfeVdq3MjOhBWIdZvc,1440
|
|
6
|
+
openevsehttp/exceptions.py,sha256=bqz-tHTW1AYJMKcm0s5M6z5tA6XZgjnCiBLW1XrZ_70,672
|
|
7
|
+
openevsehttp/managers.py,sha256=kEX1ZD9u-FY0UEZJsxeFEGBSGzSlkbBc0kmxCiMJtJw,5373
|
|
8
|
+
openevsehttp/properties.py,sha256=QVSyn_5a7vI1b4TdnnToRdw6veVCfnp7a19VYit95hg,17107
|
|
9
|
+
openevsehttp/sensors.py,sha256=sJP2FPnU1Lk5S3VUyFT14JM1nKEBQPsjl-DiZI-pZrs,4673
|
|
10
|
+
openevsehttp/utils.py,sha256=e3HH_jwZgb1iBWJgIoMOM0JPrQNwXyVdOx5vTWOh4T0,858
|
|
11
|
+
openevsehttp/websocket.py,sha256=Mi_WFmlT3-9i6bbHIN6ua09SD8CpIle2vRXB3HyWzmM,10066
|
|
12
|
+
python_openevse_http-0.0.0.dist-info/licenses/LICENSE,sha256=hSB6TOQ7rmwSGb6XzqRjDGMvmUj5_GlacqQin3tegtA,11341
|
|
13
|
+
python_openevse_http-0.0.0.dist-info/METADATA,sha256=iC4mhS9bDUxzmip1gDyiuDFohEkeJTWmfjL_FNTP5sA,4363
|
|
14
|
+
python_openevse_http-0.0.0.dist-info/WHEEL,sha256=SmOxYU7pzNKBqASvQJ7DjX3XGUF92lrGhMb3R6_iiqI,91
|
|
15
|
+
python_openevse_http-0.0.0.dist-info/top_level.txt,sha256=u8RUkoEIE33Cjn6gmqiEoVpZ0VZ59WJ3FXBwwOg0CPE,13
|
|
16
|
+
python_openevse_http-0.0.0.dist-info/RECORD,,
|