python-openevse-http 0.4.3__py3-none-any.whl → 1.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/client.py CHANGED
@@ -7,16 +7,18 @@ import inspect
7
7
  import json
8
8
  import logging
9
9
  import threading
10
- from collections.abc import Callable, Mapping
10
+ from collections.abc import Callable, Mapping, MutableMapping
11
11
  from typing import Any
12
12
 
13
- import aiohttp # type: ignore
13
+ import aiohttp
14
14
  from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError
15
15
  from awesomeversion import AwesomeVersion
16
16
  from awesomeversion.exceptions import AwesomeVersionCompareException
17
17
 
18
18
  from .commands import CommandsMixin
19
19
  from .const import (
20
+ ERROR_SESSION_LOOP_MISMATCH,
21
+ ERROR_SESSION_REQUIRED,
20
22
  ERROR_TIMEOUT,
21
23
  UPDATE_TRIGGERS,
22
24
  )
@@ -56,19 +58,29 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
56
58
  self._user = user
57
59
  self._pwd = pwd
58
60
  self.url = f"http://{host}/"
59
- self._status: dict = {}
60
- self._config: dict = {}
61
- self._override = None
61
+ self._status: dict[str, Any] = {}
62
+ self._config: dict[str, Any] = {}
63
+ self._override: Any = None
62
64
  self._ws_listening = False
63
65
  self.websocket: OpenEVSEWebsocket | None = None
64
- self.callback: Callable | None = None
66
+ self.callback: Callable[[], Any] | None = None
65
67
  self._loop: asyncio.AbstractEventLoop | None = None
66
- self._ws_listen_task: asyncio.Task | None = None
67
- self._ws_keepalive_task: asyncio.Task | None = None
68
+ self._ws_listen_task: asyncio.Task[Any] | None = None
69
+ self._ws_keepalive_task: asyncio.Task[Any] | None = None
68
70
  self._owns_loop = False
69
71
  self._loop_thread: threading.Thread | None = None
70
72
  self._session = session
71
- self._session_external = session is not None
73
+
74
+ def _get_session(self) -> aiohttp.ClientSession:
75
+ """Return the configured HTTP session or fail fast."""
76
+ if self._session is None:
77
+ raise RuntimeError(ERROR_SESSION_REQUIRED)
78
+ try:
79
+ loop = asyncio.get_running_loop()
80
+ except RuntimeError:
81
+ return self._session
82
+ self._validate_session_loop(loop)
83
+ return self._session
72
84
 
73
85
  async def process_request(
74
86
  self,
@@ -76,7 +88,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
76
88
  method: str = "",
77
89
  data: Any = None,
78
90
  rapi: Any = None,
79
- ) -> Mapping[str, Any] | list[Any] | str:
91
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
80
92
  """Return result of processed HTTP request."""
81
93
  auth = None
82
94
  allowed_methods = ["get", "post", "put", "delete", "patch", "head", "options"]
@@ -86,16 +98,10 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
86
98
  if self._user and self._pwd:
87
99
  auth = aiohttp.BasicAuth(self._user, self._pwd)
88
100
 
89
- # Use provided session or create a temporary one
90
- if (session := self._session) is None:
91
- async with aiohttp.ClientSession() as session:
92
- return await self._process_request_with_session(
93
- session, url, method, data, rapi, auth
94
- )
95
- else:
96
- return await self._process_request_with_session(
97
- session, url, method, data, rapi, auth
98
- )
101
+ session = self._get_session()
102
+ return await self._process_request_with_session(
103
+ session, url, method, data, rapi, auth
104
+ )
99
105
 
100
106
  def _normalize_response(self, response: Any) -> dict[str, Any] | list[Any]:
101
107
  """Normalize response to a dict or list."""
@@ -112,7 +118,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
112
118
  data: Any,
113
119
  rapi: Any,
114
120
  auth: Any,
115
- ) -> Mapping[str, Any] | list[Any] | str:
121
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
116
122
  """Process a request with a given session."""
117
123
  if not hasattr(session, method):
118
124
  raise MissingMethod
@@ -130,38 +136,51 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
130
136
  kwargs["json"] = data
131
137
  async with http_method(url, **kwargs) as resp:
132
138
  try:
133
- message = await resp.text()
139
+ raw = await resp.text()
134
140
  except UnicodeDecodeError:
135
141
  _LOGGER.debug("Decoding error")
136
- message = await resp.read()
137
- message = message.decode(errors="replace")
142
+ raw = (await resp.read()).decode(errors="replace")
138
143
 
144
+ # JSON responses can sometimes be primitive values (like bools).
145
+ # If json.loads fails with ValueError (e.g. non-JSON text/html),
146
+ # we fall back to treating the raw response as a string.
147
+ response_content: Mapping[str, Any] | list[Any] | str | bool = raw
139
148
  try:
140
- message = json.loads(message)
149
+ response_content = json.loads(raw)
141
150
  except ValueError:
142
- _LOGGER.debug("Non JSON response: %s", message)
151
+ _LOGGER.debug("Non JSON response: %s", raw)
152
+ if not isinstance(response_content, dict | list | str | bool):
153
+ _LOGGER.error(
154
+ "Unexpected JSON primitive response from %s: %r",
155
+ url,
156
+ response_content,
157
+ )
158
+ raise ParseJSONError
143
159
 
144
160
  if resp.status == 400:
145
- if isinstance(message, dict) and "msg" in message:
146
- _LOGGER.error("Error 400: %s", message["msg"])
147
- elif isinstance(message, dict) and "error" in message:
148
- _LOGGER.error("Error 400: %s", message["error"])
161
+ if isinstance(response_content, dict) and "msg" in response_content:
162
+ _LOGGER.error("Error 400: %s", response_content["msg"])
163
+ elif (
164
+ isinstance(response_content, dict)
165
+ and "error" in response_content
166
+ ):
167
+ _LOGGER.error("Error 400: %s", response_content["error"])
149
168
  else:
150
- _LOGGER.error("Error 400: %s", message)
169
+ _LOGGER.error("Error 400: %s", response_content)
151
170
  raise ParseJSONError
152
171
  if resp.status == 401:
153
- _LOGGER.error("Authentication error: %s", message)
172
+ _LOGGER.error("Authentication error: %s", response_content)
154
173
  raise AuthenticationError
155
174
  if resp.status in [404, 405, 500]:
156
- _LOGGER.warning("%s", message)
175
+ _LOGGER.warning("%s", response_content)
157
176
 
158
177
  if (
159
178
  method.lower() != "get"
160
- and isinstance(message, dict)
161
- and any(key in message for key in UPDATE_TRIGGERS)
179
+ and isinstance(response_content, dict)
180
+ and any(key in response_content for key in UPDATE_TRIGGERS)
162
181
  ):
163
182
  await self.update()
164
- return message
183
+ return response_content
165
184
 
166
185
  except (TimeoutError, ServerTimeoutError):
167
186
  _LOGGER.error("%s: %s", ERROR_TIMEOUT, url)
@@ -170,7 +189,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
170
189
  _LOGGER.error("Content error: %s", err.message)
171
190
  raise
172
191
 
173
- async def send_command(self, command: str) -> tuple:
192
+ async def send_command(self, command: str) -> tuple[Any, Any]:
174
193
  """Send a RAPI command to the charger and parses the response."""
175
194
  url = f"{self.url}r"
176
195
  data = {"json": 1, "rapi": command}
@@ -220,13 +239,13 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
220
239
  "Received non-JSON response from /config: %s", response
221
240
  )
222
241
 
223
- async def test_and_get(self) -> dict:
242
+ async def test_and_get(self) -> dict[str, Any]:
224
243
  """Test connection.
225
244
 
226
245
  Return model serial number as dict
227
246
  """
228
247
  url = f"{self.url}config"
229
- data = {}
248
+ data: dict[str, Any] = {}
230
249
 
231
250
  response = await self.process_request(url, method="get")
232
251
  if not isinstance(response, Mapping):
@@ -246,37 +265,38 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
246
265
  data = {"serial": serial, "model": model}
247
266
  return data
248
267
 
249
- def ws_start(self) -> None:
268
+ async def ws_start(self) -> None:
250
269
  """Start the websocket listener."""
251
270
  if self.websocket and self.websocket.state != STATE_STOPPED:
252
271
  raise AlreadyListening
253
272
 
254
- # Detect loop mismatch
255
- use_session = self._session
256
- try:
257
- asyncio.get_running_loop()
258
- except RuntimeError:
259
- # We are about to create a private loop in _start_listening
260
- # If we have a session, it's likely bound to another loop
261
- if self._session:
262
- _LOGGER.warning(
263
- "Caller-provided session may not work on private event loop. "
264
- "Creating a loop-local session."
265
- )
266
- use_session = None
267
- # Clear self._session so subsequent await self.update() uses
268
- # a loop-local session as well.
269
- self._session = None
270
- self._session_external = False
273
+ self._get_session()
271
274
 
272
275
  if not self.websocket or self.websocket.state == STATE_STOPPED:
273
- self.websocket = OpenEVSEWebsocket(
274
- self.url, self._update_status, self._user, self._pwd, use_session
275
- )
276
+ self._create_websocket()
276
277
 
277
278
  self._start_listening()
278
279
 
279
- def _start_listening(self):
280
+ def _create_websocket(self) -> None:
281
+ """Create a websocket using the configured session."""
282
+ self.websocket = OpenEVSEWebsocket(
283
+ self.url,
284
+ self._update_status,
285
+ self._user,
286
+ self._pwd,
287
+ self._session,
288
+ )
289
+
290
+ def _validate_session_loop(self, loop: asyncio.AbstractEventLoop) -> None:
291
+ """Ensure the configured session belongs to the active event loop."""
292
+ session_loop = getattr(self._session, "_loop", None)
293
+ if (
294
+ isinstance(session_loop, asyncio.AbstractEventLoop)
295
+ and session_loop is not loop
296
+ ):
297
+ raise RuntimeError(ERROR_SESSION_LOOP_MISMATCH)
298
+
299
+ def _start_listening(self) -> None:
280
300
  """Start the websocket listener."""
281
301
  if not self._loop:
282
302
  try:
@@ -300,7 +320,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
300
320
  )
301
321
  self._loop_thread.start()
302
322
 
303
- async def _update_status(self, msgtype, data, error):
323
+ async def _update_status(self, msgtype: str, data: Any, error: Any) -> None:
304
324
  """Update data from websocket listener."""
305
325
  if msgtype == SIGNAL_CONNECTION_STATE:
306
326
  uri = self.websocket.uri if self.websocket else "Unknown"
@@ -316,18 +336,18 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
316
336
  self._ws_listening = False
317
337
 
318
338
  # Stopped websockets without errors are expected during shutdown
319
- # and ignored
320
- elif data == STATE_STOPPED and error:
321
- _LOGGER.debug(
322
- "Websocket to %s failed, aborting [Error: %s]",
323
- uri,
324
- error,
325
- )
339
+ elif data == STATE_STOPPED:
340
+ if error:
341
+ _LOGGER.debug(
342
+ "Websocket to %s failed, aborting [Error: %s]",
343
+ uri,
344
+ error,
345
+ )
326
346
  self._ws_listening = False
327
347
 
328
348
  elif msgtype == "data":
329
349
  _LOGGER.debug("Websocket data: %s", data)
330
- if not isinstance(data, Mapping):
350
+ if not isinstance(data, MutableMapping):
331
351
  _LOGGER.warning("Received non-Mapping websocket data: %s", data)
332
352
  return
333
353
 
@@ -354,7 +374,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
354
374
  if inspect.isawaitable(result):
355
375
  await result
356
376
 
357
- async def _shutdown(self):
377
+ async def _shutdown(self) -> None:
358
378
  """Shutdown the websocket and tasks on the listener loop."""
359
379
  tasks = []
360
380
  if self._ws_keepalive_task:
@@ -417,7 +437,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
417
437
  # Standard async disconnect for caller loop
418
438
  await self._shutdown()
419
439
 
420
- def is_coroutine_function(self, callback):
440
+ def is_coroutine_function(self, callback: Any) -> bool:
421
441
  """Check if a callback is a coroutine function."""
422
442
  return inspect.iscoroutinefunction(callback)
423
443
 
@@ -428,7 +448,13 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
428
448
  return STATE_STOPPED
429
449
  return self.websocket.state
430
450
 
431
- async def repeat(self, interval, func, *args, **kwargs):
451
+ async def repeat(
452
+ self,
453
+ interval: float,
454
+ func: Callable[..., Any],
455
+ *args: Any,
456
+ **kwargs: Any,
457
+ ) -> None:
432
458
  """Run func every interval seconds.
433
459
 
434
460
  If func has not finished before *interval*, will run again
@@ -436,10 +462,12 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
436
462
 
437
463
  *args and **kwargs are passed as the arguments to func.
438
464
  """
439
- while self.ws_state != STATE_STOPPED and self._ws_listening:
465
+ while self.ws_state != STATE_STOPPED:
440
466
  await asyncio.sleep(interval)
441
- if self.ws_state == STATE_STOPPED or not self._ws_listening:
467
+ if self.ws_state == STATE_STOPPED:
442
468
  break
469
+ if not self._ws_listening:
470
+ continue
443
471
  result = func(*args, **kwargs)
444
472
  if inspect.isawaitable(result):
445
473
  await result
openevsehttp/commands.py CHANGED
@@ -7,7 +7,7 @@ import logging
7
7
  from collections.abc import Mapping
8
8
  from typing import Any
9
9
 
10
- import aiohttp # type: ignore
10
+ import aiohttp
11
11
  from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError
12
12
  from awesomeversion import AwesomeVersion
13
13
  from awesomeversion.exceptions import AwesomeVersionCompareException
@@ -23,9 +23,9 @@ class CommandsMixin:
23
23
  """Mixin providing command methods for OpenEVSE."""
24
24
 
25
25
  url: str
26
- _status: dict
27
- _config: dict
28
- _session: Any
26
+ _status: dict[str, Any]
27
+ _config: dict[str, Any]
28
+ _session: aiohttp.ClientSession | None
29
29
 
30
30
  # These are defined in client.py
31
31
  def _version_check(self, min_version: str, max_version: str = "") -> bool:
@@ -33,10 +33,10 @@ class CommandsMixin:
33
33
 
34
34
  async def process_request(
35
35
  self, url: str, method: str = "", data: Any = None, rapi: Any = None
36
- ) -> Mapping[str, Any] | list[Any] | str:
36
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
37
37
  raise NotImplementedError
38
38
 
39
- async def send_command(self, command: str) -> tuple:
39
+ async def send_command(self, command: str) -> tuple[Any, Any]:
40
40
  raise NotImplementedError
41
41
 
42
42
  async def update(self, force_status: bool = False) -> None:
@@ -46,6 +46,10 @@ class CommandsMixin:
46
46
  """Normalize response to a dict or list."""
47
47
  raise NotImplementedError
48
48
 
49
+ def _get_session(self) -> aiohttp.ClientSession:
50
+ """Return the configured HTTP session."""
51
+ raise NotImplementedError
52
+
49
53
  def _flag_ota_if_started(self, response: Any) -> None:
50
54
  """Flag OTA as active if response indicates firmware update has started."""
51
55
  normalized = self._normalize_response(response)
@@ -382,7 +386,7 @@ class CommandsMixin:
382
386
  _LOGGER.debug("EVSE Restart response: %s", response)
383
387
 
384
388
  # Firmware version
385
- async def firmware_check(self) -> dict | None:
389
+ async def firmware_check(self) -> dict[str, Any] | None:
386
390
  """Return the latest firmware version."""
387
391
  if "version" not in self._config:
388
392
  # Throw warning if we can't find the version
@@ -409,12 +413,8 @@ class CommandsMixin:
409
413
  return None
410
414
 
411
415
  try:
412
- if (session := self._session) is None:
413
- async with aiohttp.ClientSession() as session:
414
- return await self._firmware_check_with_session(session, url, method)
415
- else:
416
- return await self._firmware_check_with_session(session, url, method)
417
-
416
+ session = self._get_session()
417
+ return await self._firmware_check_with_session(session, url, method)
418
418
  except (TimeoutError, ServerTimeoutError):
419
419
  _LOGGER.error("%s: %s", "Timeout while updating", url)
420
420
  except ContentTypeError as err:
@@ -426,7 +426,7 @@ class CommandsMixin:
426
426
 
427
427
  async def _firmware_check_with_session(
428
428
  self, session: aiohttp.ClientSession, url: str, method: str
429
- ) -> dict | None:
429
+ ) -> dict[str, Any] | None:
430
430
  """Process a firmware check request with a given session."""
431
431
  http_method = getattr(session, method)
432
432
  _LOGGER.debug(
@@ -504,7 +504,7 @@ class CommandsMixin:
504
504
  firmware_url: str | None = None,
505
505
  firmware_bytes: bytes | None = None,
506
506
  filename: str = "firmware.bin",
507
- ) -> Mapping[str, Any] | list[Any] | str:
507
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
508
508
  """Instruct the device to update its firmware.
509
509
 
510
510
  You can either:
openevsehttp/const.py CHANGED
@@ -62,3 +62,10 @@ RAPI_ERRORS = [
62
62
  ]
63
63
 
64
64
  SUCCESS_ANSWERS = ["OK", "done", "no change", "Created", "Updated", "Deleted"]
65
+
66
+ ERROR_SESSION_REQUIRED = (
67
+ "An aiohttp.ClientSession must be provided via the session argument."
68
+ )
69
+ ERROR_SESSION_LOOP_MISMATCH = (
70
+ "The aiohttp.ClientSession is bound to a different event loop."
71
+ )
openevsehttp/managers.py CHANGED
@@ -23,7 +23,7 @@ class ManagersMixin:
23
23
 
24
24
  async def process_request(
25
25
  self, url: str, method: str = "", data: Any = None, rapi: Any = None
26
- ) -> Mapping[str, Any] | list[Any] | str:
26
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
27
27
  raise NotImplementedError
28
28
 
29
29
  def _normalize_response(self, response: Any) -> dict[str, Any] | list[Any]:
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import logging
6
6
  from collections.abc import Mapping
7
7
  from datetime import datetime, timedelta, timezone
8
- from typing import Any
8
+ from typing import Any, cast
9
9
 
10
10
  from .const import MAX_AMPS, MIN_AMPS, states
11
11
  from .exceptions import UnsupportedFeature
@@ -17,8 +17,8 @@ _LOGGER = logging.getLogger(__name__)
17
17
  class PropertiesMixin:
18
18
  """Mixin providing all @property accessors for OpenEVSE."""
19
19
 
20
- _status: dict
21
- _config: dict
20
+ _status: dict[str, Any]
21
+ _config: dict[str, Any]
22
22
 
23
23
  # These are used by properties but defined in client.py
24
24
  def _version_check(self, min_version: str, max_version: str = "") -> bool:
@@ -125,15 +125,15 @@ class PropertiesMixin:
125
125
  @property
126
126
  def max_current(self) -> int | None:
127
127
  """Return the max current."""
128
- return self._status.get("max_current", None)
128
+ return cast(int | None, self._status.get("max_current", None))
129
129
 
130
130
  @property
131
131
  def wifi_firmware(self) -> str | None:
132
132
  """Return the ESP firmware version."""
133
133
  value = self._config.get("version")
134
- if value is not None:
135
- value = normalize_version(value)
136
- return value
134
+ if value is None:
135
+ return None
136
+ return normalize_version(value)
137
137
 
138
138
  @property
139
139
  def ip_address(self) -> str | None:
@@ -303,27 +303,30 @@ class PropertiesMixin:
303
303
  @property
304
304
  def total_day(self) -> float | None:
305
305
  """Get the total day energy usage."""
306
- return self._status.get("total_day", None)
306
+ return cast(float | None, self._status.get("total_day", None))
307
307
 
308
308
  @property
309
309
  def total_week(self) -> float | None:
310
310
  """Get the total week energy usage."""
311
- return self._status.get("total_week", None)
311
+ return cast(float | None, self._status.get("total_week", None))
312
312
 
313
313
  @property
314
314
  def total_month(self) -> float | None:
315
315
  """Get the total month energy usage."""
316
- return self._status.get("total_month", None)
316
+ return cast(float | None, self._status.get("total_month", None))
317
317
 
318
318
  @property
319
319
  def total_year(self) -> float | None:
320
320
  """Get the total year energy usage."""
321
- return self._status.get("total_year", None)
321
+ return cast(float | None, self._status.get("total_year", None))
322
322
 
323
323
  @property
324
324
  def has_limit(self) -> bool | None:
325
325
  """Return if a limit has been set."""
326
- return self._status.get("has_limit", self._status.get("limit", None))
326
+ return cast(
327
+ "bool | None",
328
+ self._status.get("has_limit", self._status.get("limit", None)),
329
+ )
327
330
 
328
331
  @property
329
332
  def protocol_version(self) -> str | None:
@@ -336,7 +339,7 @@ class PropertiesMixin:
336
339
  @property
337
340
  def vehicle(self) -> bool:
338
341
  """Return if a vehicle is connected to the EVSE."""
339
- return self._status.get("vehicle", False)
342
+ return bool(self._status.get("vehicle", False))
340
343
 
341
344
  @property
342
345
  def ota_update(self) -> bool:
@@ -356,7 +359,7 @@ class PropertiesMixin:
356
359
  @property
357
360
  def manual_override(self) -> bool:
358
361
  """Return if Manual Override is set."""
359
- return self._status.get("manual_override", False)
362
+ return bool(self._status.get("manual_override", False))
360
363
 
361
364
  @property
362
365
  def divertmode(self) -> str:
@@ -394,7 +397,7 @@ class PropertiesMixin:
394
397
  @property
395
398
  def wifi_serial(self) -> str | None:
396
399
  """Return wifi serial."""
397
- return self._config.get("wifi_serial", None)
400
+ return cast(str | None, self._config.get("wifi_serial", None))
398
401
 
399
402
  @property
400
403
  def charging_power(self) -> float | None:
@@ -415,12 +418,12 @@ class PropertiesMixin:
415
418
  @property
416
419
  def shaper_active(self) -> bool | None:
417
420
  """Return if shaper is active."""
418
- return self._status.get("shaper", None)
421
+ return cast(bool | None, self._status.get("shaper", None))
419
422
 
420
423
  @property
421
424
  def shaper_live_power(self) -> int | None:
422
425
  """Return shaper live power reading."""
423
- return self._status.get("shaper_live_pwr", None)
426
+ return cast(int | None, self._status.get("shaper_live_pwr", None))
424
427
 
425
428
  @property
426
429
  def shaper_available_current(self) -> float | None:
@@ -433,7 +436,7 @@ class PropertiesMixin:
433
436
  @property
434
437
  def shaper_max_power(self) -> int | None:
435
438
  """Return shaper live power reading."""
436
- return self._status.get("shaper_max_pwr", None)
439
+ return cast(int | None, self._status.get("shaper_max_pwr", None))
437
440
 
438
441
  @property
439
442
  def shaper_updated(self) -> bool:
@@ -444,13 +447,17 @@ class PropertiesMixin:
444
447
  @property
445
448
  def vehicle_soc(self) -> int | None:
446
449
  """Return battery level."""
447
- return self._status.get("vehicle_soc", self._status.get("battery_level", None))
450
+ return cast(
451
+ "int | None",
452
+ self._status.get("vehicle_soc", self._status.get("battery_level", None)),
453
+ )
448
454
 
449
455
  @property
450
456
  def vehicle_range(self) -> int | None:
451
457
  """Return battery range."""
452
- return self._status.get(
453
- "vehicle_range", self._status.get("battery_range", None)
458
+ return cast(
459
+ "int | None",
460
+ self._status.get("vehicle_range", self._status.get("battery_range", None)),
454
461
  )
455
462
 
456
463
  @property
@@ -472,12 +479,12 @@ class PropertiesMixin:
472
479
  @property
473
480
  def min_amps(self) -> int:
474
481
  """Return the minimum amps."""
475
- return self._config.get("min_current_hard", MIN_AMPS)
482
+ return int(self._config.get("min_current_hard", MIN_AMPS))
476
483
 
477
484
  @property
478
485
  def max_amps(self) -> int:
479
486
  """Return the maximum amps."""
480
- return self._config.get("max_current_hard", MAX_AMPS)
487
+ return int(self._config.get("max_current_hard", MAX_AMPS))
481
488
 
482
489
  @property
483
490
  def mqtt_connected(self) -> bool:
@@ -487,29 +494,29 @@ class PropertiesMixin:
487
494
  @property
488
495
  def emoncms_connected(self) -> bool | None:
489
496
  """Return the status of the emoncms connection."""
490
- return self._status.get("emoncms_connected", None)
497
+ return cast(bool | None, self._status.get("emoncms_connected", None))
491
498
 
492
499
  @property
493
500
  def ocpp_connected(self) -> bool | None:
494
501
  """Return the status of the ocpp connection."""
495
- return self._status.get("ocpp_connected", None)
502
+ return cast(bool | None, self._status.get("ocpp_connected", None))
496
503
 
497
504
  @property
498
505
  def uptime(self) -> int | None:
499
506
  """Return the unit uptime."""
500
- return self._status.get("uptime", None)
507
+ return cast(int | None, self._status.get("uptime", None))
501
508
 
502
509
  @property
503
510
  def freeram(self) -> int | None:
504
511
  """Return the unit freeram."""
505
- return self._status.get("freeram", None)
512
+ return cast(int | None, self._status.get("freeram", None))
506
513
 
507
514
  # Safety counts
508
515
  @property
509
- def checks_count(self) -> dict:
516
+ def checks_count(self) -> dict[str, Any]:
510
517
  """Return the safety checks counts."""
511
518
  attributes = ("gfcicount", "nogndcount", "stuckcount")
512
- counts = {}
519
+ counts: dict[str, Any] = {}
513
520
  if self._status is not None and set(attributes).issubset(self._status.keys()):
514
521
  counts["gfcicount"] = self._status["gfcicount"]
515
522
  counts["nogndcount"] = self._status["nogndcount"]
@@ -534,4 +541,4 @@ class PropertiesMixin:
534
541
  if not self._version_check("4.2.2"):
535
542
  _LOGGER.debug("Feature not supported for older firmware.")
536
543
  raise UnsupportedFeature
537
- return self._status.get("power", 0)
544
+ return int(self._status.get("power", 0))
openevsehttp/py.typed ADDED
File without changes
openevsehttp/sensors.py CHANGED
@@ -23,7 +23,7 @@ class SensorsMixin:
23
23
 
24
24
  async def process_request(
25
25
  self, url: str, method: str = "", data: Any = None, rapi: Any = None
26
- ) -> Mapping[str, Any] | list[Any] | str:
26
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
27
27
  raise NotImplementedError
28
28
 
29
29
  def _normalize_response(self, response: Any) -> dict[str, Any] | list[Any]:
openevsehttp/websocket.py CHANGED
@@ -1,12 +1,21 @@
1
1
  """Websocket class for OpenEVSE HTTP."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import asyncio
4
6
  import datetime
5
7
  import inspect
6
8
  import logging
9
+ from collections.abc import Awaitable, Callable
10
+ from typing import Any
7
11
 
8
12
  import aiohttp
9
13
 
14
+ from .const import (
15
+ ERROR_SESSION_LOOP_MISMATCH,
16
+ ERROR_SESSION_REQUIRED,
17
+ )
18
+
10
19
  _LOGGER = logging.getLogger(__name__)
11
20
 
12
21
  MAX_FAILED_ATTEMPTS = 5
@@ -28,35 +37,34 @@ class OpenEVSEWebsocket:
28
37
 
29
38
  def __init__(
30
39
  self,
31
- server,
32
- callback,
33
- user=None,
34
- password=None,
40
+ server: str,
41
+ callback: Callable[[str, Any, Any], Any] | None,
42
+ user: str | None = None,
43
+ password: str | None = None,
35
44
  session: aiohttp.ClientSession | None = None,
36
- ):
45
+ ) -> None:
37
46
  """Initialize a OpenEVSEWebsocket instance."""
38
47
  self.session = session
39
- self._session_external = session is not None
40
48
  self.uri = self._get_uri(server)
41
49
  self._user = user
42
50
  self._password = password
43
51
  self.callback = callback
44
52
  self._state = STATE_DISCONNECTED
45
53
  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()
54
+ self._error_reason: Any = None
55
+ self._client: aiohttp.ClientWebSocketResponse | None = None
56
+ self._ping: datetime.datetime | None = None
57
+ self._pong: datetime.datetime | None = None
58
+ self._tasks: set[asyncio.Task[Any]] = set()
51
59
  self._listener_loop: asyncio.AbstractEventLoop | None = None
52
60
 
53
61
  @property
54
- def state(self):
62
+ def state(self) -> str:
55
63
  """Return the current state."""
56
64
  return self._state
57
65
 
58
66
  @state.setter
59
- def state(self, value):
67
+ def state(self, value: str) -> None:
60
68
  """Setter that schedules the callback."""
61
69
  self._state = value
62
70
  _LOGGER.debug("Websocket %s", value)
@@ -76,21 +84,16 @@ class OpenEVSEWebsocket:
76
84
  if self._listener_loop:
77
85
  self._listener_loop.call_soon_threadsafe(self._schedule_task, coro)
78
86
  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
+ task = asyncio.ensure_future(coro)
88
+ self._tasks.add(task)
89
+ task.add_done_callback(self._tasks.discard)
87
90
  except RuntimeError:
88
91
  _LOGGER.error("Failed to schedule callback from sync context: %s", coro)
89
92
  if hasattr(coro, "close"):
90
93
  coro.close()
91
94
  self._error_reason = None
92
95
 
93
- def _schedule_task(self, coro):
96
+ def _schedule_task(self, coro: Awaitable[Any]) -> None:
94
97
  """Schedule a task from a thread-safe context."""
95
98
  try:
96
99
  task = asyncio.ensure_future(coro)
@@ -103,7 +106,7 @@ class OpenEVSEWebsocket:
103
106
  if hasattr(coro, "close"):
104
107
  coro.close()
105
108
 
106
- async def _set_state(self, value):
109
+ async def _set_state(self, value: str) -> None:
107
110
  """Async helper to set the state and await the callback."""
108
111
  self._state = value
109
112
  _LOGGER.debug("Websocket %s", value)
@@ -114,11 +117,11 @@ class OpenEVSEWebsocket:
114
117
  self._error_reason = None
115
118
 
116
119
  @staticmethod
117
- def _get_uri(server):
120
+ def _get_uri(server: str) -> str:
118
121
  """Generate the websocket URI."""
119
122
  return server[: server.rfind("/")].replace("http", "ws") + "/ws"
120
123
 
121
- async def running(self):
124
+ async def running(self) -> None:
122
125
  """Open a persistent websocket connection and act on events."""
123
126
  await self._ensure_session()
124
127
  await self._set_state(STATE_STARTING)
@@ -128,6 +131,8 @@ class OpenEVSEWebsocket:
128
131
  auth = aiohttp.BasicAuth(self._user, self._password)
129
132
 
130
133
  try:
134
+ # Narrow type for mypy since _ensure_session sets self.session
135
+ assert self.session is not None
131
136
  async with self.session.ws_connect(
132
137
  self.uri,
133
138
  heartbeat=15,
@@ -156,7 +161,9 @@ class OpenEVSEWebsocket:
156
161
  await self._client.close()
157
162
  self._client = None
158
163
 
159
- async def _handle_messages(self, ws_client):
164
+ async def _handle_messages(
165
+ self, ws_client: aiohttp.ClientWebSocketResponse
166
+ ) -> None:
160
167
  """Handle incoming websocket messages."""
161
168
  async for message in ws_client:
162
169
  if self.state == STATE_STOPPED:
@@ -183,7 +190,7 @@ class OpenEVSEWebsocket:
183
190
  _LOGGER.error("Websocket error")
184
191
  break
185
192
 
186
- async def _handle_response_error(self, error):
193
+ async def _handle_response_error(self, error: aiohttp.ClientResponseError) -> None:
187
194
  """Handle ClientResponseError."""
188
195
  if error.status == 401:
189
196
  _LOGGER.error("Credentials rejected: %s", error)
@@ -193,7 +200,7 @@ class OpenEVSEWebsocket:
193
200
  self._error_reason = error
194
201
  await self._set_state(STATE_STOPPED)
195
202
 
196
- async def _handle_connection_error(self, error):
203
+ async def _handle_connection_error(self, error: BaseException) -> None:
197
204
  """Handle connection errors."""
198
205
  self.failed_attempts += 1
199
206
  if self.failed_attempts > MAX_FAILED_ATTEMPTS:
@@ -209,7 +216,7 @@ class OpenEVSEWebsocket:
209
216
  await self._set_state(STATE_DISCONNECTED)
210
217
  await asyncio.sleep(retry_delay)
211
218
 
212
- async def listen(self):
219
+ async def listen(self) -> None:
213
220
  """Start the listening websocket."""
214
221
  await self._ensure_session()
215
222
  self.failed_attempts = 0
@@ -220,13 +227,20 @@ class OpenEVSEWebsocket:
220
227
  finally:
221
228
  self._listener_loop = None
222
229
 
223
- async def _ensure_session(self):
224
- """Ensure aiohttp.ClientSession exists."""
230
+ async def _ensure_session(self) -> None:
231
+ """Ensure an external aiohttp.ClientSession exists."""
225
232
  if self.session is None:
226
- self.session = aiohttp.ClientSession()
227
- self._session_external = False
233
+ raise RuntimeError(ERROR_SESSION_REQUIRED)
234
+
235
+ loop = asyncio.get_running_loop()
236
+ session_loop = getattr(self.session, "_loop", None)
237
+ if (
238
+ isinstance(session_loop, asyncio.AbstractEventLoop)
239
+ and session_loop is not loop
240
+ ):
241
+ raise RuntimeError(ERROR_SESSION_LOOP_MISMATCH)
228
242
 
229
- async def close(self):
243
+ async def close(self) -> None:
230
244
  """Close the listening websocket."""
231
245
  await self._set_state(STATE_STOPPED)
232
246
 
@@ -239,12 +253,8 @@ class OpenEVSEWebsocket:
239
253
  if self._client is not None:
240
254
  await self._client.close()
241
255
  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
256
 
247
- async def keepalive(self):
257
+ async def keepalive(self) -> None:
248
258
  """Send ping requests to websocket."""
249
259
  if self._ping and self._pong:
250
260
  time_delta = self._pong - self._ping
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python_openevse_http
3
- Version: 0.4.3
3
+ Version: 1.0.0
4
4
  Summary: Python wrapper for OpenEVSE HTTP API
5
5
  Home-page: https://github.com/firstof9/python-openevse-http
6
6
  Download-URL: https://github.com/firstof9/python-openevse-http
@@ -62,28 +62,24 @@ pip install python_openevse_http
62
62
 
63
63
  ```python
64
64
  import asyncio
65
+ import aiohttp
65
66
  from openevsehttp import OpenEVSE
66
67
 
67
68
  async def main():
68
- # Initialize the charger
69
- charger = OpenEVSE("192.168.1.30")
69
+ async with aiohttp.ClientSession() as session:
70
+ charger = OpenEVSE("192.168.1.30", session=session)
71
+ await charger.update()
70
72
 
71
- # Update state
72
- await charger.update()
73
+ print(f"Charger State: {charger.status}")
74
+ print(f"Current Charge: {charger.charge_current}A")
73
75
 
74
- print(f"Charger State: {charger.status}")
75
- print(f"Current Charge: {charger.charge_current}A")
76
+ if charger.shaper_active:
77
+ print("Shaper is active, disabling...")
78
+ else:
79
+ print("Shaper is inactive, enabling...")
76
80
 
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()
81
+ await charger.toggle_shaper()
82
+ await charger.ws_disconnect()
87
83
 
88
84
  if __name__ == "__main__":
89
85
  asyncio.run(main())
@@ -97,7 +93,7 @@ if __name__ == "__main__":
97
93
  | `/config` | GET, POST | ✅ | System and WiFi configuration |
98
94
  | `/override` | GET, POST, PATCH, DELETE | ✅ | Manual charging overrides & current limits |
99
95
  | `/claims` | GET, POST, DELETE | ✅ | Client-based charging claims |
100
- | `/schedule` | GET, POST | | Charging schedule management |
96
+ | `/schedule` | GET, POST | ⚠️ | Charging schedule management (Retrieval only) |
101
97
  | `/limit` | GET, POST, DELETE | ✅ | Charge limits (Time, Energy, SoC) |
102
98
  | `/shaper` | POST | ✅ | Grid shaper control (v4.0.0+) |
103
99
  | `/restart` | POST | ✅ | Reboot WiFi gateway or EVSE module |
@@ -111,7 +107,7 @@ if __name__ == "__main__":
111
107
  | `/tesla` | GET | ❌ | Tesla vehicle integration |
112
108
  | `/certificates`| GET, POST, DELETE | ❌ | SSL/TLS certificate management |
113
109
  | `/schedule/plan`| GET | ❌ | Schedule planning and optimization |
114
- | `/update` | POST | | Firmware update interface |
110
+ | `/update` | POST | | Firmware update interface |
115
111
  | `/rfid/add` | POST | ❌ | RFID tag management |
116
112
 
117
113
  ✅ = Fully Supported \| ⚠️ = Partial Support \| ❌ = Not yet implemented
@@ -0,0 +1,17 @@
1
+ openevsehttp/__init__.py,sha256=I6a1mjOZHYiWb_qfCuDuFLOOncrkkB_7uwybtOIujfY,1165
2
+ openevsehttp/__main__.py,sha256=EHmSdT7GjAVvHQxvLBTjZXsj_V5SB6B2_kpgUAT7mPM,146
3
+ openevsehttp/client.py,sha256=tw8MGwraUVn5ERbQCR6JRWEvMRc7KnUgJvf_0viOQHo,18983
4
+ openevsehttp/commands.py,sha256=JGxjmGvE2-eUJ1gD76y_701Qk3Kzab-6XtGeaXXDSb0,27243
5
+ openevsehttp/const.py,sha256=9jVzW4CZz7uP7VKbGQT4v0jNeP5PPC55tR-jnG11ODA,1646
6
+ openevsehttp/exceptions.py,sha256=bqz-tHTW1AYJMKcm0s5M6z5tA6XZgjnCiBLW1XrZ_70,672
7
+ openevsehttp/managers.py,sha256=EtQMQziwhoZeqKe2zWY-0yS7zedhuYjYtL5j9xBBCZ0,5380
8
+ openevsehttp/properties.py,sha256=psGGiRacHYs1YYmagiWSlpfua-SnpU2nCviEYDZX0V8,17892
9
+ openevsehttp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ openevsehttp/sensors.py,sha256=yO4Q1sgJkvmfOi5dpoTwEoldsrpqgGz0k9fC7mFWass,4680
11
+ openevsehttp/utils.py,sha256=e3HH_jwZgb1iBWJgIoMOM0JPrQNwXyVdOx5vTWOh4T0,858
12
+ openevsehttp/websocket.py,sha256=dOSemvm7CM5pFb9RaoJSTIM6Tf5xtWMLEBEccKlBUKI,10517
13
+ python_openevse_http-1.0.0.dist-info/licenses/LICENSE,sha256=hSB6TOQ7rmwSGb6XzqRjDGMvmUj5_GlacqQin3tegtA,11341
14
+ python_openevse_http-1.0.0.dist-info/METADATA,sha256=fYgJfqxD6zmc8b540T6jStiE1AKNUmkqSC2vmHHHRjo,4417
15
+ python_openevse_http-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
16
+ python_openevse_http-1.0.0.dist-info/top_level.txt,sha256=u8RUkoEIE33Cjn6gmqiEoVpZ0VZ59WJ3FXBwwOg0CPE,13
17
+ python_openevse_http-1.0.0.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- openevsehttp/__init__.py,sha256=I6a1mjOZHYiWb_qfCuDuFLOOncrkkB_7uwybtOIujfY,1165
2
- openevsehttp/__main__.py,sha256=EHmSdT7GjAVvHQxvLBTjZXsj_V5SB6B2_kpgUAT7mPM,146
3
- openevsehttp/client.py,sha256=2SGL0RKZp08t_hGXHKIIRGk8wyrNJRcSXQE7nc0P9UU,17951
4
- openevsehttp/commands.py,sha256=vZFzNFg6-DqbpavTulypXOGCDwPChvBzoxIHXBlJrBc,27216
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=9fmJo6xU8im-Dd2QoH8f2E-0veD8uL1QQYiaERvIRr8,17430
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.4.3.dist-info/licenses/LICENSE,sha256=hSB6TOQ7rmwSGb6XzqRjDGMvmUj5_GlacqQin3tegtA,11341
13
- python_openevse_http-0.4.3.dist-info/METADATA,sha256=ThGhSK3mu1cGbRBLb4a5El7qhUNERBu6rC9tjdiHd20,4363
14
- python_openevse_http-0.4.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
15
- python_openevse_http-0.4.3.dist-info/top_level.txt,sha256=u8RUkoEIE33Cjn6gmqiEoVpZ0VZ59WJ3FXBwwOg0CPE,13
16
- python_openevse_http-0.4.3.dist-info/RECORD,,