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/commands.py
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""Command methods for the OpenEVSE charger."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import aiohttp # type: ignore
|
|
11
|
+
from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError
|
|
12
|
+
from awesomeversion import AwesomeVersion
|
|
13
|
+
from awesomeversion.exceptions import AwesomeVersionCompareException
|
|
14
|
+
|
|
15
|
+
from .const import MAX_AMPS, MIN_AMPS, RAPI_ERRORS, SUCCESS_ANSWERS, divert_mode
|
|
16
|
+
from .exceptions import UnknownError, UnsupportedFeature
|
|
17
|
+
from .utils import get_awesome_version
|
|
18
|
+
|
|
19
|
+
_LOGGER = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CommandsMixin:
|
|
23
|
+
"""Mixin providing command methods for OpenEVSE."""
|
|
24
|
+
|
|
25
|
+
url: str
|
|
26
|
+
_status: dict
|
|
27
|
+
_config: dict
|
|
28
|
+
_session: Any
|
|
29
|
+
|
|
30
|
+
# These are defined in client.py
|
|
31
|
+
def _version_check(self, min_version: str, max_version: str = "") -> bool:
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
|
|
34
|
+
async def process_request(
|
|
35
|
+
self, url: str, method: str = "", data: Any = None, rapi: Any = None
|
|
36
|
+
) -> Mapping[str, Any] | list[Any] | str:
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
|
|
39
|
+
async def send_command(self, command: str) -> tuple:
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
async def update(self) -> None:
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
def _normalize_response(self, response: Any) -> dict[str, Any] | list[Any]:
|
|
46
|
+
"""Normalize response to a dict or list."""
|
|
47
|
+
raise NotImplementedError
|
|
48
|
+
|
|
49
|
+
async def get_schedule(self) -> Mapping[str, Any] | list[Any]:
|
|
50
|
+
"""Return the current schedule."""
|
|
51
|
+
url = f"{self.url}schedule"
|
|
52
|
+
|
|
53
|
+
_LOGGER.debug("Getting current schedule from %s", url)
|
|
54
|
+
response = await self.process_request(url=url, method="post")
|
|
55
|
+
return self._normalize_response(response)
|
|
56
|
+
|
|
57
|
+
async def set_charge_mode(self, mode: str = "fast") -> None:
|
|
58
|
+
"""Set the charge mode at startup setting."""
|
|
59
|
+
url = f"{self.url}config"
|
|
60
|
+
|
|
61
|
+
if mode not in ["fast", "eco"]:
|
|
62
|
+
_LOGGER.error("Invalid value for charge_mode: %s", mode)
|
|
63
|
+
raise ValueError
|
|
64
|
+
|
|
65
|
+
data = {"charge_mode": mode}
|
|
66
|
+
|
|
67
|
+
_LOGGER.debug("Setting charge mode to %s", mode)
|
|
68
|
+
response = await self.process_request(url=url, method="post", data=data)
|
|
69
|
+
response = self._normalize_response(response)
|
|
70
|
+
msg = response.get("msg") if isinstance(response, Mapping) else None
|
|
71
|
+
if msg not in SUCCESS_ANSWERS:
|
|
72
|
+
_LOGGER.error("Problem issuing command: %s", response)
|
|
73
|
+
raise UnknownError
|
|
74
|
+
|
|
75
|
+
async def divert_mode(self) -> Mapping[str, Any] | list[Any]:
|
|
76
|
+
"""Set the divert mode to either Normal or Eco modes."""
|
|
77
|
+
if not self._config:
|
|
78
|
+
raise RuntimeError("Missing configuration: self._config is required")
|
|
79
|
+
|
|
80
|
+
if not self._version_check("2.9.1"):
|
|
81
|
+
_LOGGER.debug("Feature not supported for older firmware.")
|
|
82
|
+
raise UnsupportedFeature
|
|
83
|
+
|
|
84
|
+
if "divert_enabled" in self._config:
|
|
85
|
+
_LOGGER.debug("Divert Enabled: %s", self._config["divert_enabled"])
|
|
86
|
+
mode = not self._config["divert_enabled"]
|
|
87
|
+
else:
|
|
88
|
+
_LOGGER.debug("Unable to check divert status.")
|
|
89
|
+
raise UnsupportedFeature
|
|
90
|
+
|
|
91
|
+
url = f"{self.url}config"
|
|
92
|
+
data = {"divert_enabled": mode}
|
|
93
|
+
|
|
94
|
+
_LOGGER.debug("Toggling divert: %s", mode)
|
|
95
|
+
response = await self.process_request(url=url, method="post", data=data)
|
|
96
|
+
_LOGGER.debug("divert_mode response: %s", response)
|
|
97
|
+
normalized_response = self._normalize_response(response)
|
|
98
|
+
if (
|
|
99
|
+
isinstance(normalized_response, dict)
|
|
100
|
+
and normalized_response.get("msg") in SUCCESS_ANSWERS
|
|
101
|
+
):
|
|
102
|
+
self._config["divert_enabled"] = mode
|
|
103
|
+
return normalized_response
|
|
104
|
+
|
|
105
|
+
async def get_override(self) -> Mapping[str, Any] | list[Any]:
|
|
106
|
+
"""Get the manual override status."""
|
|
107
|
+
if not self._version_check("4.0.1"):
|
|
108
|
+
_LOGGER.debug("Feature not supported for older firmware.")
|
|
109
|
+
raise UnsupportedFeature
|
|
110
|
+
url = f"{self.url}override"
|
|
111
|
+
|
|
112
|
+
_LOGGER.debug("Getting data from %s", url)
|
|
113
|
+
response = await self.process_request(url=url, method="get")
|
|
114
|
+
return self._normalize_response(response)
|
|
115
|
+
|
|
116
|
+
async def set_override(
|
|
117
|
+
self,
|
|
118
|
+
state: str | None = None,
|
|
119
|
+
charge_current: int | None = None,
|
|
120
|
+
max_current: int | None = None,
|
|
121
|
+
energy_limit: int | None = None,
|
|
122
|
+
time_limit: int | None = None,
|
|
123
|
+
auto_release: bool | None = None,
|
|
124
|
+
) -> Any:
|
|
125
|
+
"""Set the manual override status."""
|
|
126
|
+
if not self._version_check("4.0.1"):
|
|
127
|
+
_LOGGER.debug("Feature not supported for older firmware.")
|
|
128
|
+
raise UnsupportedFeature
|
|
129
|
+
url = f"{self.url}override"
|
|
130
|
+
|
|
131
|
+
response = await self.get_override()
|
|
132
|
+
if not isinstance(response, Mapping) or (
|
|
133
|
+
len(response) == 1 and "msg" in response
|
|
134
|
+
):
|
|
135
|
+
_LOGGER.error("Invalid override payload: %s", response)
|
|
136
|
+
raise ValueError("Invalid override state response")
|
|
137
|
+
|
|
138
|
+
if state not in ["active", "disabled", None]:
|
|
139
|
+
_LOGGER.error("Invalid override state: %s", state)
|
|
140
|
+
raise ValueError
|
|
141
|
+
|
|
142
|
+
data: dict[str, Any] = {}
|
|
143
|
+
if auto_release is not None:
|
|
144
|
+
data["auto_release"] = auto_release
|
|
145
|
+
|
|
146
|
+
if state is not None:
|
|
147
|
+
data["state"] = state
|
|
148
|
+
if charge_current is not None:
|
|
149
|
+
data["charge_current"] = charge_current
|
|
150
|
+
if max_current is not None:
|
|
151
|
+
data["max_current"] = max_current
|
|
152
|
+
if energy_limit is not None:
|
|
153
|
+
data["energy_limit"] = energy_limit
|
|
154
|
+
if time_limit is not None:
|
|
155
|
+
data["time_limit"] = time_limit
|
|
156
|
+
|
|
157
|
+
_LOGGER.debug("Override data: %s", data)
|
|
158
|
+
_LOGGER.debug("Setting override config on %s", url)
|
|
159
|
+
reply = await self.process_request(url=url, method="post", data=data)
|
|
160
|
+
return self._normalize_response(reply)
|
|
161
|
+
|
|
162
|
+
async def toggle_override(self) -> None:
|
|
163
|
+
"""Toggle the manual override status."""
|
|
164
|
+
# 3.x: use RAPI commands $FE (enable) and $FS (sleep)
|
|
165
|
+
# 4.x: use HTTP API call
|
|
166
|
+
lower = "4.0.1"
|
|
167
|
+
if self._version_check(lower):
|
|
168
|
+
url = f"{self.url}override"
|
|
169
|
+
|
|
170
|
+
_LOGGER.debug("Toggling manual override %s", url)
|
|
171
|
+
response = await self.process_request(url=url, method="patch")
|
|
172
|
+
response = self._normalize_response(response)
|
|
173
|
+
_LOGGER.debug("Toggle response: %s", response)
|
|
174
|
+
if (
|
|
175
|
+
not isinstance(response, Mapping)
|
|
176
|
+
or response.get("msg") not in SUCCESS_ANSWERS
|
|
177
|
+
):
|
|
178
|
+
_LOGGER.error("Problem toggling override: %s", response)
|
|
179
|
+
raise RuntimeError(f"Failed to toggle override: {response}")
|
|
180
|
+
else:
|
|
181
|
+
# Older firmware use RAPI commands
|
|
182
|
+
_LOGGER.debug("Toggling manual override via RAPI")
|
|
183
|
+
if "state" not in self._status:
|
|
184
|
+
await self.update()
|
|
185
|
+
|
|
186
|
+
if "state" not in self._status:
|
|
187
|
+
_LOGGER.error("Cannot toggle override: unknown charger state.")
|
|
188
|
+
raise RuntimeError("Cannot toggle override: unknown charger state.")
|
|
189
|
+
|
|
190
|
+
command = "$FE" if self._status.get("state") == 254 else "$FS"
|
|
191
|
+
response, msg = await self.send_command(command)
|
|
192
|
+
_LOGGER.debug("Toggle response: %s", msg)
|
|
193
|
+
if response in [False, "NK"] or (
|
|
194
|
+
isinstance(msg, str) and (msg.startswith("$NK") or msg in RAPI_ERRORS)
|
|
195
|
+
):
|
|
196
|
+
_LOGGER.error("Problem toggling override via RAPI: %s", msg)
|
|
197
|
+
raise RuntimeError(f"Failed to toggle override via RAPI: {msg}")
|
|
198
|
+
|
|
199
|
+
async def clear_override(self) -> None:
|
|
200
|
+
"""Clear the manual override status."""
|
|
201
|
+
if not self._version_check("4.0.1"):
|
|
202
|
+
_LOGGER.debug("Feature not supported for older firmware.")
|
|
203
|
+
raise UnsupportedFeature
|
|
204
|
+
url = f"{self.url}override"
|
|
205
|
+
|
|
206
|
+
_LOGGER.debug("Clearing manual override %s", url)
|
|
207
|
+
response = await self.process_request(url=url, method="delete")
|
|
208
|
+
response = self._normalize_response(response)
|
|
209
|
+
msg = response.get("msg") if isinstance(response, Mapping) else None
|
|
210
|
+
_LOGGER.debug("Clear override response: %s", msg)
|
|
211
|
+
if msg not in SUCCESS_ANSWERS:
|
|
212
|
+
_LOGGER.error("Problem clearing override: %s", response)
|
|
213
|
+
raise RuntimeError(f"Failed to clear override: {response}")
|
|
214
|
+
|
|
215
|
+
async def set_current(self, amps: int = 6) -> None:
|
|
216
|
+
"""Set the soft current limit."""
|
|
217
|
+
# 3.x - 4.1.0: use RAPI commands $SC <amps>
|
|
218
|
+
# 4.1.2: use HTTP API call
|
|
219
|
+
if isinstance(amps, bool) or not isinstance(amps, int):
|
|
220
|
+
_LOGGER.error("Invalid type for current limit: %s (%s)", amps, type(amps))
|
|
221
|
+
raise ValueError(
|
|
222
|
+
f"Current limit must be an integer, got {type(amps).__name__}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
min_current = self._config.get("min_current_hard", MIN_AMPS)
|
|
226
|
+
max_current = self._config.get("max_current_hard", MAX_AMPS)
|
|
227
|
+
if amps < min_current or amps > max_current:
|
|
228
|
+
_LOGGER.error("Invalid value for current limit: %s", amps)
|
|
229
|
+
raise ValueError(
|
|
230
|
+
f"Current limit {amps} is out of range ({min_current}-{max_current})"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if self._version_check("4.1.2"):
|
|
234
|
+
_LOGGER.debug("Setting current limit to %s", amps)
|
|
235
|
+
response = await self.set_override(charge_current=amps)
|
|
236
|
+
_LOGGER.debug("Set current response: %s", response)
|
|
237
|
+
if (
|
|
238
|
+
not isinstance(response, Mapping)
|
|
239
|
+
or response.get("msg") not in SUCCESS_ANSWERS
|
|
240
|
+
):
|
|
241
|
+
_LOGGER.error("Problem setting current limit: %s", response)
|
|
242
|
+
raise UnknownError
|
|
243
|
+
|
|
244
|
+
else:
|
|
245
|
+
# RAPI commands
|
|
246
|
+
_LOGGER.debug("Setting current via RAPI")
|
|
247
|
+
command = f"$SC {amps} N"
|
|
248
|
+
# Different parameters for older firmware
|
|
249
|
+
if self._version_check("2.9.1"):
|
|
250
|
+
command = f"$SC {amps} V"
|
|
251
|
+
response, msg = await self.send_command(command)
|
|
252
|
+
_LOGGER.debug("Set current response: %s", msg)
|
|
253
|
+
if response in [False, "NK"] or (
|
|
254
|
+
isinstance(msg, str) and (msg.startswith("$NK") or msg in RAPI_ERRORS)
|
|
255
|
+
):
|
|
256
|
+
_LOGGER.error("Problem setting current via RAPI: %s", msg)
|
|
257
|
+
raise UnknownError
|
|
258
|
+
|
|
259
|
+
async def set_service_level(self, level: int | str = 2) -> None:
|
|
260
|
+
"""Set the service level of the EVSE."""
|
|
261
|
+
if isinstance(level, bool) or (
|
|
262
|
+
not (isinstance(level, int) and 0 <= level <= 2) and level != "A"
|
|
263
|
+
):
|
|
264
|
+
_LOGGER.error("Invalid service level: %s", level)
|
|
265
|
+
raise ValueError
|
|
266
|
+
|
|
267
|
+
url = f"{self.url}config"
|
|
268
|
+
data = {"service": level}
|
|
269
|
+
|
|
270
|
+
_LOGGER.debug("Set service level to: %s", level)
|
|
271
|
+
response = await self.process_request(url=url, method="post", data=data)
|
|
272
|
+
response = self._normalize_response(response)
|
|
273
|
+
_LOGGER.debug("service response: %s", response)
|
|
274
|
+
msg = response.get("msg") if isinstance(response, Mapping) else None
|
|
275
|
+
if msg not in SUCCESS_ANSWERS:
|
|
276
|
+
_LOGGER.error("Problem issuing command: %s", response)
|
|
277
|
+
raise UnknownError
|
|
278
|
+
|
|
279
|
+
# Restart OpenEVSE WiFi
|
|
280
|
+
async def restart_wifi(self) -> None:
|
|
281
|
+
"""Restart OpenEVSE WiFi module."""
|
|
282
|
+
url = f"{self.url}restart"
|
|
283
|
+
data = {"device": "gateway"}
|
|
284
|
+
|
|
285
|
+
response = await self.process_request(url=url, method="post", data=data)
|
|
286
|
+
response = self._normalize_response(response)
|
|
287
|
+
|
|
288
|
+
msg = (
|
|
289
|
+
response.get("msg", "Unknown error")
|
|
290
|
+
if isinstance(response, Mapping)
|
|
291
|
+
else "Unknown error"
|
|
292
|
+
)
|
|
293
|
+
_LOGGER.debug("WiFi Restart response: %s", msg)
|
|
294
|
+
|
|
295
|
+
# Strict success check:
|
|
296
|
+
# 1. Must be a Mapping
|
|
297
|
+
# 2. Must have "result" == "OK" OR "success" is True
|
|
298
|
+
# 3. Must NOT have an "error" key
|
|
299
|
+
# 4. If "msg" is present, it must be "OK" or contain "ok" (case-insensitive)
|
|
300
|
+
success = (
|
|
301
|
+
isinstance(response, Mapping)
|
|
302
|
+
and (response.get("result") == "OK" or response.get("success") is True)
|
|
303
|
+
and not response.get("error")
|
|
304
|
+
)
|
|
305
|
+
if success and isinstance(response, Mapping) and "msg" in response:
|
|
306
|
+
msg_val = str(response["msg"]).lower()
|
|
307
|
+
if msg_val != "ok" and "ok" not in msg_val:
|
|
308
|
+
success = False
|
|
309
|
+
|
|
310
|
+
if not success:
|
|
311
|
+
_LOGGER.error("Problem restarting WiFi: %s", response)
|
|
312
|
+
raise RuntimeError(f"Failed to restart WiFi: {msg}")
|
|
313
|
+
|
|
314
|
+
# Restart EVSE module
|
|
315
|
+
async def restart_evse(self) -> None:
|
|
316
|
+
"""Restart EVSE module."""
|
|
317
|
+
if self._version_check("5.0.0"):
|
|
318
|
+
_LOGGER.debug("Restarting EVSE module via HTTP")
|
|
319
|
+
url = f"{self.url}restart"
|
|
320
|
+
data = {"device": "evse"}
|
|
321
|
+
reply = await self.process_request(url=url, method="post", data=data)
|
|
322
|
+
reply = self._normalize_response(reply)
|
|
323
|
+
if not isinstance(reply, Mapping) or (
|
|
324
|
+
reply.get("result") not in (None, 0, "OK", "ok", True)
|
|
325
|
+
or reply.get("msg") in RAPI_ERRORS
|
|
326
|
+
or reply.get("msg") in ["NK", False]
|
|
327
|
+
or reply.get("error")
|
|
328
|
+
):
|
|
329
|
+
_LOGGER.error("Problem restarting EVSE module via HTTP: %s", reply)
|
|
330
|
+
raise RuntimeError(f"Failed to restart EVSE module via HTTP: {reply}")
|
|
331
|
+
|
|
332
|
+
response = (
|
|
333
|
+
reply.get("msg", "Unknown error")
|
|
334
|
+
if isinstance(reply, Mapping)
|
|
335
|
+
else "Unknown error"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
else:
|
|
339
|
+
_LOGGER.debug("Restarting EVSE module via RAPI")
|
|
340
|
+
command = "$FR"
|
|
341
|
+
reply, response = await self.send_command(command)
|
|
342
|
+
if reply in [False, "NK"] or (
|
|
343
|
+
isinstance(response, str)
|
|
344
|
+
and (response.startswith("$NK") or response in RAPI_ERRORS)
|
|
345
|
+
):
|
|
346
|
+
_LOGGER.error("Problem restarting EVSE module via RAPI: %s", response)
|
|
347
|
+
raise RuntimeError(
|
|
348
|
+
f"Failed to restart EVSE module via RAPI: {response}"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
_LOGGER.debug("EVSE Restart response: %s", response)
|
|
352
|
+
|
|
353
|
+
# Firmware version
|
|
354
|
+
async def firmware_check(self) -> dict | None:
|
|
355
|
+
"""Return the latest firmware version."""
|
|
356
|
+
if "version" not in self._config:
|
|
357
|
+
# Throw warning if we can't find the version
|
|
358
|
+
_LOGGER.debug("Unable to find firmware version.")
|
|
359
|
+
return None
|
|
360
|
+
base_url = "https://api.github.com/repos/OpenEVSE/"
|
|
361
|
+
url = None
|
|
362
|
+
method = "get"
|
|
363
|
+
|
|
364
|
+
cutoff = AwesomeVersion("3.0.0")
|
|
365
|
+
_LOGGER.debug("Detected firmware: %s", self._config["version"])
|
|
366
|
+
|
|
367
|
+
current = get_awesome_version(self._config["version"])
|
|
368
|
+
_LOGGER.debug("Using version: %s", current)
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
if current >= cutoff:
|
|
372
|
+
url = f"{base_url}ESP32_WiFi_V4.x/releases/latest"
|
|
373
|
+
else:
|
|
374
|
+
url = f"{base_url}ESP8266_WiFi_v2.x/releases/latest"
|
|
375
|
+
except AwesomeVersionCompareException:
|
|
376
|
+
_LOGGER.debug("Non-semver firmware version detected.")
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
try:
|
|
380
|
+
if (session := self._session) is None:
|
|
381
|
+
async with aiohttp.ClientSession() as session:
|
|
382
|
+
return await self._firmware_check_with_session(session, url, method)
|
|
383
|
+
else:
|
|
384
|
+
return await self._firmware_check_with_session(session, url, method)
|
|
385
|
+
|
|
386
|
+
except (TimeoutError, ServerTimeoutError):
|
|
387
|
+
_LOGGER.error("%s: %s", "Timeout while updating", url)
|
|
388
|
+
except ContentTypeError as err:
|
|
389
|
+
_LOGGER.error("%s", err)
|
|
390
|
+
except aiohttp.ClientConnectorError as err:
|
|
391
|
+
_LOGGER.error("%s : %s", err, url)
|
|
392
|
+
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
async def _firmware_check_with_session(
|
|
396
|
+
self, session: aiohttp.ClientSession, url: str, method: str
|
|
397
|
+
) -> dict | None:
|
|
398
|
+
"""Process a firmware check request with a given session."""
|
|
399
|
+
http_method = getattr(session, method)
|
|
400
|
+
_LOGGER.debug(
|
|
401
|
+
"Connecting to %s using method %s",
|
|
402
|
+
url,
|
|
403
|
+
method,
|
|
404
|
+
)
|
|
405
|
+
async with http_method(url) as resp:
|
|
406
|
+
if resp.status != 200:
|
|
407
|
+
return None
|
|
408
|
+
message = await resp.text()
|
|
409
|
+
try:
|
|
410
|
+
message = json.loads(message)
|
|
411
|
+
except json.JSONDecodeError:
|
|
412
|
+
_LOGGER.error("Failed to parse JSON response: %s", message)
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
if not isinstance(message, dict):
|
|
416
|
+
return None
|
|
417
|
+
return {
|
|
418
|
+
"latest_version": message.get("tag_name"),
|
|
419
|
+
"release_notes": message.get("body"),
|
|
420
|
+
"release_url": message.get("html_url"),
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async def set_led_brightness(self, level: int) -> None:
|
|
424
|
+
"""Set LED brightness level."""
|
|
425
|
+
if isinstance(level, bool) or not isinstance(level, int):
|
|
426
|
+
_LOGGER.error(
|
|
427
|
+
"Invalid type for LED brightness: %s (%s)", level, type(level)
|
|
428
|
+
)
|
|
429
|
+
raise TypeError(
|
|
430
|
+
f"LED brightness must be an integer, got {type(level).__name__}"
|
|
431
|
+
)
|
|
432
|
+
if not 0 <= level <= 255:
|
|
433
|
+
_LOGGER.error("Invalid value for LED brightness: %s", level)
|
|
434
|
+
raise ValueError(f"LED brightness {level} is out of range (0-255)")
|
|
435
|
+
|
|
436
|
+
if not self._version_check("4.1.0"):
|
|
437
|
+
_LOGGER.debug("Feature not supported for older firmware.")
|
|
438
|
+
raise UnsupportedFeature
|
|
439
|
+
|
|
440
|
+
url = f"{self.url}config"
|
|
441
|
+
data: dict[str, Any] = {}
|
|
442
|
+
|
|
443
|
+
data["led_brightness"] = level
|
|
444
|
+
_LOGGER.debug("Setting LED brightness to %s", level)
|
|
445
|
+
response = await self.process_request(url=url, method="post", data=data)
|
|
446
|
+
response = self._normalize_response(response)
|
|
447
|
+
msg = response.get("msg") if isinstance(response, Mapping) else None
|
|
448
|
+
if msg not in SUCCESS_ANSWERS:
|
|
449
|
+
_LOGGER.error("Problem issuing command: %s", response)
|
|
450
|
+
raise UnknownError
|
|
451
|
+
|
|
452
|
+
async def set_divert_mode(self, mode: str = "fast") -> None:
|
|
453
|
+
"""Set the divert mode."""
|
|
454
|
+
url = f"{self.url}divertmode"
|
|
455
|
+
if mode not in ["fast", "eco"]:
|
|
456
|
+
_LOGGER.error("Invalid value for divert mode: %s", mode)
|
|
457
|
+
raise ValueError
|
|
458
|
+
_LOGGER.debug("Setting divert mode to %s", mode)
|
|
459
|
+
# convert text to int
|
|
460
|
+
new_mode = divert_mode[mode]
|
|
461
|
+
|
|
462
|
+
data = f"divertmode={new_mode}"
|
|
463
|
+
|
|
464
|
+
response = await self.process_request(url=url, method="post", rapi=data)
|
|
465
|
+
success = False
|
|
466
|
+
if isinstance(response, str):
|
|
467
|
+
res_lower = response.lower()
|
|
468
|
+
if "divert" in res_lower and "changed" in res_lower:
|
|
469
|
+
success = True
|
|
470
|
+
elif isinstance(response, dict) and response.get("msg") in SUCCESS_ANSWERS:
|
|
471
|
+
success = True
|
|
472
|
+
|
|
473
|
+
if not success:
|
|
474
|
+
_LOGGER.error("Problem issuing command: %s", response)
|
|
475
|
+
raise UnknownError
|
|
476
|
+
|
|
477
|
+
self._status["divertmode"] = new_mode
|
|
478
|
+
|
|
479
|
+
async def set_shaper(self, enable: bool = True) -> None:
|
|
480
|
+
"""Set shaper mode."""
|
|
481
|
+
if not self._version_check("4.0.0"):
|
|
482
|
+
_LOGGER.debug("Feature not supported for older firmware.")
|
|
483
|
+
raise UnsupportedFeature
|
|
484
|
+
|
|
485
|
+
url = f"{self.url}shaper"
|
|
486
|
+
mode = 1 if enable else 0
|
|
487
|
+
data = {"shaper": mode}
|
|
488
|
+
|
|
489
|
+
_LOGGER.debug("Setting shaper to %s", mode)
|
|
490
|
+
response = await self.process_request(url=url, method="post", rapi=data)
|
|
491
|
+
response = self._normalize_response(response)
|
|
492
|
+
msg = response.get("msg") if isinstance(response, Mapping) else None
|
|
493
|
+
if msg not in SUCCESS_ANSWERS and msg != "Current Shaper state changed":
|
|
494
|
+
_LOGGER.error("Problem issuing command: %s", response)
|
|
495
|
+
raise UnknownError
|
|
496
|
+
|
|
497
|
+
self._status["shaper"] = mode
|
|
498
|
+
|
|
499
|
+
async def toggle_shaper(self) -> None:
|
|
500
|
+
"""Toggle shaper mode."""
|
|
501
|
+
shaper_active = self._status.get("shaper")
|
|
502
|
+
if shaper_active is None:
|
|
503
|
+
await self.update()
|
|
504
|
+
shaper_active = self._status.get("shaper")
|
|
505
|
+
|
|
506
|
+
if shaper_active is None:
|
|
507
|
+
_LOGGER.error("Cannot toggle shaper: unknown shaper state.")
|
|
508
|
+
raise RuntimeError("Cannot toggle shaper: unknown shaper state.")
|
|
509
|
+
|
|
510
|
+
new_state = not bool(shaper_active)
|
|
511
|
+
await self.set_shaper(new_state)
|
openevsehttp/const.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Constants for the OpenEVSE HTTP python library."""
|
|
2
|
+
|
|
3
|
+
USER_AGENT = "python-openevse-http"
|
|
4
|
+
MIN_AMPS = 6
|
|
5
|
+
MAX_AMPS = 48
|
|
6
|
+
|
|
7
|
+
ERROR_TIMEOUT = "Timeout while updating"
|
|
8
|
+
INFO_LOOP_RUNNING = "Event loop already running, not creating new one."
|
|
9
|
+
UPDATE_TRIGGERS = [
|
|
10
|
+
"config_version",
|
|
11
|
+
"claims_version",
|
|
12
|
+
"override_version",
|
|
13
|
+
"schedule_version",
|
|
14
|
+
"schedule_plan_version",
|
|
15
|
+
"limit_version",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
SOLAR = "solar"
|
|
19
|
+
|
|
20
|
+
GRID = "grid_ie"
|
|
21
|
+
BAT_LVL = "battery_level"
|
|
22
|
+
BAT_RANGE = "battery_range"
|
|
23
|
+
TTF = "time_to_full_charge"
|
|
24
|
+
VOLTAGE = "voltage"
|
|
25
|
+
SHAPER_LIVE = "shaper_live_pwr"
|
|
26
|
+
TYPE = "type"
|
|
27
|
+
VALUE = "value"
|
|
28
|
+
RELEASE = "release"
|
|
29
|
+
# https://github.com/OpenEVSE/openevse_esp32_firmware/blob/master/src/evse_man.h#L28
|
|
30
|
+
CLIENT = 20
|
|
31
|
+
|
|
32
|
+
states = {
|
|
33
|
+
0: "unknown",
|
|
34
|
+
1: "not connected",
|
|
35
|
+
2: "connected",
|
|
36
|
+
3: "charging",
|
|
37
|
+
4: "vent required",
|
|
38
|
+
5: "diode check failed",
|
|
39
|
+
6: "gfci fault",
|
|
40
|
+
7: "no ground",
|
|
41
|
+
8: "stuck relay",
|
|
42
|
+
9: "gfci self-test failure",
|
|
43
|
+
10: "over temperature",
|
|
44
|
+
254: "sleeping",
|
|
45
|
+
255: "disabled",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
divert_mode = {
|
|
49
|
+
"fast": 1,
|
|
50
|
+
"eco": 2,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
RAPI_ERRORS = [
|
|
54
|
+
"RAPI_RESPONSE_QUEUE_FULL",
|
|
55
|
+
"RAPI_RESPONSE_BUFFER_OVERFLOW",
|
|
56
|
+
"RAPI_RESPONSE_TIMEOUT",
|
|
57
|
+
"RAPI_RESPONSE_INVALID_RESPONSE",
|
|
58
|
+
"RAPI_RESPONSE_CMD_TOO_LONG",
|
|
59
|
+
"RAPI_RESPONSE_NOT_FOUND",
|
|
60
|
+
"RAPI_RESPONSE_BLOCKED",
|
|
61
|
+
"RAPI_RESPONSE_INVALID_COMMAND",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
SUCCESS_ANSWERS = ["OK", "done", "no change", "Created", "Updated", "Deleted"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AuthenticationError(Exception):
|
|
5
|
+
"""Exception for authentication errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ParseJSONError(Exception):
|
|
9
|
+
"""Exception for JSON parsing errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UnknownError(Exception):
|
|
13
|
+
"""Exception for Unknown errors."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MissingMethod(Exception):
|
|
17
|
+
"""Exception for missing method variable."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AlreadyListening(Exception):
|
|
21
|
+
"""Exception for already listening websocket."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MissingSerial(Exception):
|
|
25
|
+
"""Exception for missing serial number."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UnsupportedFeature(Exception):
|
|
29
|
+
"""Exception for firmware that is too old."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InvalidType(Exception):
|
|
33
|
+
"""Exception for invalid types."""
|