python-openevse-http 0.4.2__py3-none-any.whl → 0.4.4__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,10 +7,10 @@ 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
@@ -56,15 +56,15 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
56
56
  self._user = user
57
57
  self._pwd = pwd
58
58
  self.url = f"http://{host}/"
59
- self._status: dict = {}
60
- self._config: dict = {}
61
- self._override = None
59
+ self._status: dict[str, Any] = {}
60
+ self._config: dict[str, Any] = {}
61
+ self._override: Any = None
62
62
  self._ws_listening = False
63
63
  self.websocket: OpenEVSEWebsocket | None = None
64
- self.callback: Callable | None = None
64
+ self.callback: Callable[[], Any] | None = None
65
65
  self._loop: asyncio.AbstractEventLoop | None = None
66
- self._ws_listen_task: asyncio.Task | None = None
67
- self._ws_keepalive_task: asyncio.Task | None = None
66
+ self._ws_listen_task: asyncio.Task[Any] | None = None
67
+ self._ws_keepalive_task: asyncio.Task[Any] | None = None
68
68
  self._owns_loop = False
69
69
  self._loop_thread: threading.Thread | None = None
70
70
  self._session = session
@@ -76,7 +76,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
76
76
  method: str = "",
77
77
  data: Any = None,
78
78
  rapi: Any = None,
79
- ) -> Mapping[str, Any] | list[Any] | str:
79
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
80
80
  """Return result of processed HTTP request."""
81
81
  auth = None
82
82
  allowed_methods = ["get", "post", "put", "delete", "patch", "head", "options"]
@@ -112,7 +112,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
112
112
  data: Any,
113
113
  rapi: Any,
114
114
  auth: Any,
115
- ) -> Mapping[str, Any] | list[Any] | str:
115
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
116
116
  """Process a request with a given session."""
117
117
  if not hasattr(session, method):
118
118
  raise MissingMethod
@@ -130,38 +130,51 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
130
130
  kwargs["json"] = data
131
131
  async with http_method(url, **kwargs) as resp:
132
132
  try:
133
- message = await resp.text()
133
+ raw = await resp.text()
134
134
  except UnicodeDecodeError:
135
135
  _LOGGER.debug("Decoding error")
136
- message = await resp.read()
137
- message = message.decode(errors="replace")
136
+ raw = (await resp.read()).decode(errors="replace")
138
137
 
138
+ # JSON responses can sometimes be primitive values (like bools).
139
+ # If json.loads fails with ValueError (e.g. non-JSON text/html),
140
+ # we fall back to treating the raw response as a string.
141
+ response_content: Mapping[str, Any] | list[Any] | str | bool = raw
139
142
  try:
140
- message = json.loads(message)
143
+ response_content = json.loads(raw)
141
144
  except ValueError:
142
- _LOGGER.debug("Non JSON response: %s", message)
145
+ _LOGGER.debug("Non JSON response: %s", raw)
146
+ if not isinstance(response_content, dict | list | str | bool):
147
+ _LOGGER.error(
148
+ "Unexpected JSON primitive response from %s: %r",
149
+ url,
150
+ response_content,
151
+ )
152
+ raise ParseJSONError
143
153
 
144
154
  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"])
155
+ if isinstance(response_content, dict) and "msg" in response_content:
156
+ _LOGGER.error("Error 400: %s", response_content["msg"])
157
+ elif (
158
+ isinstance(response_content, dict)
159
+ and "error" in response_content
160
+ ):
161
+ _LOGGER.error("Error 400: %s", response_content["error"])
149
162
  else:
150
- _LOGGER.error("Error 400: %s", message)
163
+ _LOGGER.error("Error 400: %s", response_content)
151
164
  raise ParseJSONError
152
165
  if resp.status == 401:
153
- _LOGGER.error("Authentication error: %s", message)
166
+ _LOGGER.error("Authentication error: %s", response_content)
154
167
  raise AuthenticationError
155
168
  if resp.status in [404, 405, 500]:
156
- _LOGGER.warning("%s", message)
169
+ _LOGGER.warning("%s", response_content)
157
170
 
158
171
  if (
159
172
  method.lower() != "get"
160
- and isinstance(message, dict)
161
- and any(key in message for key in UPDATE_TRIGGERS)
173
+ and isinstance(response_content, dict)
174
+ and any(key in response_content for key in UPDATE_TRIGGERS)
162
175
  ):
163
176
  await self.update()
164
- return message
177
+ return response_content
165
178
 
166
179
  except (TimeoutError, ServerTimeoutError):
167
180
  _LOGGER.error("%s: %s", ERROR_TIMEOUT, url)
@@ -170,7 +183,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
170
183
  _LOGGER.error("Content error: %s", err.message)
171
184
  raise
172
185
 
173
- async def send_command(self, command: str) -> tuple:
186
+ async def send_command(self, command: str) -> tuple[Any, Any]:
174
187
  """Send a RAPI command to the charger and parses the response."""
175
188
  url = f"{self.url}r"
176
189
  data = {"json": 1, "rapi": command}
@@ -220,13 +233,13 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
220
233
  "Received non-JSON response from /config: %s", response
221
234
  )
222
235
 
223
- async def test_and_get(self) -> dict:
236
+ async def test_and_get(self) -> dict[str, Any]:
224
237
  """Test connection.
225
238
 
226
239
  Return model serial number as dict
227
240
  """
228
241
  url = f"{self.url}config"
229
- data = {}
242
+ data: dict[str, Any] = {}
230
243
 
231
244
  response = await self.process_request(url, method="get")
232
245
  if not isinstance(response, Mapping):
@@ -276,7 +289,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
276
289
 
277
290
  self._start_listening()
278
291
 
279
- def _start_listening(self):
292
+ def _start_listening(self) -> None:
280
293
  """Start the websocket listener."""
281
294
  if not self._loop:
282
295
  try:
@@ -300,7 +313,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
300
313
  )
301
314
  self._loop_thread.start()
302
315
 
303
- async def _update_status(self, msgtype, data, error):
316
+ async def _update_status(self, msgtype: str, data: Any, error: Any) -> None:
304
317
  """Update data from websocket listener."""
305
318
  if msgtype == SIGNAL_CONNECTION_STATE:
306
319
  uri = self.websocket.uri if self.websocket else "Unknown"
@@ -316,18 +329,18 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
316
329
  self._ws_listening = False
317
330
 
318
331
  # 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
- )
332
+ elif data == STATE_STOPPED:
333
+ if error:
334
+ _LOGGER.debug(
335
+ "Websocket to %s failed, aborting [Error: %s]",
336
+ uri,
337
+ error,
338
+ )
326
339
  self._ws_listening = False
327
340
 
328
341
  elif msgtype == "data":
329
342
  _LOGGER.debug("Websocket data: %s", data)
330
- if not isinstance(data, Mapping):
343
+ if not isinstance(data, MutableMapping):
331
344
  _LOGGER.warning("Received non-Mapping websocket data: %s", data)
332
345
  return
333
346
 
@@ -354,7 +367,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
354
367
  if inspect.isawaitable(result):
355
368
  await result
356
369
 
357
- async def _shutdown(self):
370
+ async def _shutdown(self) -> None:
358
371
  """Shutdown the websocket and tasks on the listener loop."""
359
372
  tasks = []
360
373
  if self._ws_keepalive_task:
@@ -417,7 +430,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
417
430
  # Standard async disconnect for caller loop
418
431
  await self._shutdown()
419
432
 
420
- def is_coroutine_function(self, callback):
433
+ def is_coroutine_function(self, callback: Any) -> bool:
421
434
  """Check if a callback is a coroutine function."""
422
435
  return inspect.iscoroutinefunction(callback)
423
436
 
@@ -428,7 +441,13 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
428
441
  return STATE_STOPPED
429
442
  return self.websocket.state
430
443
 
431
- async def repeat(self, interval, func, *args, **kwargs):
444
+ async def repeat(
445
+ self,
446
+ interval: float,
447
+ func: Callable[..., Any],
448
+ *args: Any,
449
+ **kwargs: Any,
450
+ ) -> None:
432
451
  """Run func every interval seconds.
433
452
 
434
453
  If func has not finished before *interval*, will run again
@@ -436,10 +455,12 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
436
455
 
437
456
  *args and **kwargs are passed as the arguments to func.
438
457
  """
439
- while self.ws_state != STATE_STOPPED and self._ws_listening:
458
+ while self.ws_state != STATE_STOPPED:
440
459
  await asyncio.sleep(interval)
441
- if self.ws_state == STATE_STOPPED or not self._ws_listening:
460
+ if self.ws_state == STATE_STOPPED:
442
461
  break
462
+ if not self._ws_listening:
463
+ continue
443
464
  result = func(*args, **kwargs)
444
465
  if inspect.isawaitable(result):
445
466
  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,8 +23,8 @@ class CommandsMixin:
23
23
  """Mixin providing command methods for OpenEVSE."""
24
24
 
25
25
  url: str
26
- _status: dict
27
- _config: dict
26
+ _status: dict[str, Any]
27
+ _config: dict[str, Any]
28
28
  _session: Any
29
29
 
30
30
  # These are defined in client.py
@@ -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:
@@ -53,7 +53,12 @@ class CommandsMixin:
53
53
  normalized.get("msg") == "started"
54
54
  or normalized.get("msg") in SUCCESS_ANSWERS
55
55
  ):
56
+ _LOGGER.debug("Firmware update started, setting ota_update flag.")
56
57
  self._status["ota_update"] = 1
58
+ else:
59
+ _LOGGER.debug(
60
+ "Firmware update response did not indicate start: %s", normalized
61
+ )
57
62
 
58
63
  async def get_schedule(self) -> Mapping[str, Any] | list[Any]:
59
64
  """Return the current schedule."""
@@ -131,7 +136,12 @@ class CommandsMixin:
131
136
  time_limit: int | None = None,
132
137
  auto_release: bool | None = None,
133
138
  ) -> Any:
134
- """Set the manual override status."""
139
+ """Set the manual override status.
140
+
141
+ Fetches the current override payload first and merges existing values
142
+ into the request payload. This prevents the firmware from clearing/resetting
143
+ previously configured properties that are not passed in the function call.
144
+ """
135
145
  if not self._version_check("4.0.1"):
136
146
  _LOGGER.debug("Feature not supported for older firmware.")
137
147
  raise UnsupportedFeature
@@ -149,6 +159,18 @@ class CommandsMixin:
149
159
  raise ValueError
150
160
 
151
161
  data: dict[str, Any] = {}
162
+ if isinstance(response, Mapping):
163
+ for key in (
164
+ "state",
165
+ "charge_current",
166
+ "max_current",
167
+ "energy_limit",
168
+ "time_limit",
169
+ "auto_release",
170
+ ):
171
+ if key in response:
172
+ data[key] = response[key]
173
+
152
174
  if auto_release is not None:
153
175
  data["auto_release"] = auto_release
154
176
 
@@ -360,7 +382,7 @@ class CommandsMixin:
360
382
  _LOGGER.debug("EVSE Restart response: %s", response)
361
383
 
362
384
  # Firmware version
363
- async def firmware_check(self) -> dict | None:
385
+ async def firmware_check(self) -> dict[str, Any] | None:
364
386
  """Return the latest firmware version."""
365
387
  if "version" not in self._config:
366
388
  # Throw warning if we can't find the version
@@ -381,6 +403,7 @@ class CommandsMixin:
381
403
  url = f"{base_url}ESP32_WiFi_V4.x/releases/latest"
382
404
  else:
383
405
  url = f"{base_url}ESP8266_WiFi_v2.x/releases/latest"
406
+ _LOGGER.debug("Firmware check URL: %s", url)
384
407
  except AwesomeVersionCompareException:
385
408
  _LOGGER.debug("Non-semver firmware version detected.")
386
409
  return None
@@ -403,7 +426,7 @@ class CommandsMixin:
403
426
 
404
427
  async def _firmware_check_with_session(
405
428
  self, session: aiohttp.ClientSession, url: str, method: str
406
- ) -> dict | None:
429
+ ) -> dict[str, Any] | None:
407
430
  """Process a firmware check request with a given session."""
408
431
  http_method = getattr(session, method)
409
432
  _LOGGER.debug(
@@ -412,6 +435,7 @@ class CommandsMixin:
412
435
  method,
413
436
  )
414
437
  async with http_method(url) as resp:
438
+ _LOGGER.debug("Firmware check response status: %d", resp.status)
415
439
  if resp.status != 200:
416
440
  return None
417
441
  message = await resp.text()
@@ -422,19 +446,51 @@ class CommandsMixin:
422
446
  return None
423
447
 
424
448
  if not isinstance(message, dict):
449
+ _LOGGER.debug(
450
+ "Invalid JSON response type from GitHub: %s", type(message)
451
+ )
425
452
  return None
426
453
 
454
+ _LOGGER.debug(
455
+ "GitHub release metadata successfully fetched for version: %s",
456
+ message.get("tag_name"),
457
+ )
458
+
427
459
  # Match browser_download_url based on buildenv
428
460
  download_url = None
429
461
  buildenv = self._config.get("buildenv")
430
462
  assets = message.get("assets", [])
431
463
 
432
- if buildenv and assets:
464
+ if not buildenv:
465
+ _LOGGER.debug(
466
+ "Cannot resolve firmware asset: missing buildenv in config."
467
+ )
468
+ assets = []
469
+ elif not isinstance(assets, list):
470
+ _LOGGER.debug("Invalid GitHub assets payload: %r", assets)
471
+ assets = []
472
+ else:
473
+ _LOGGER.debug("Matching buildenv '%s' against assets", buildenv)
433
474
  target_filename = f"{buildenv}.bin"
434
475
  for asset in assets:
476
+ if not isinstance(asset, Mapping):
477
+ continue
435
478
  if asset.get("name") == target_filename:
436
479
  download_url = asset.get("browser_download_url")
480
+ _LOGGER.debug("Found matching firmware asset: %s", download_url)
437
481
  break
482
+ if buildenv and not download_url:
483
+ _LOGGER.debug(
484
+ "Could not find asset matching target filename '%s.bin' in assets: %s",
485
+ buildenv,
486
+ [
487
+ asset.get("name")
488
+ for asset in assets
489
+ if isinstance(asset, Mapping)
490
+ ]
491
+ if assets
492
+ else "None",
493
+ )
438
494
 
439
495
  return {
440
496
  "latest_version": message.get("tag_name"),
@@ -448,7 +504,7 @@ class CommandsMixin:
448
504
  firmware_url: str | None = None,
449
505
  firmware_bytes: bytes | None = None,
450
506
  filename: str = "firmware.bin",
451
- ) -> Mapping[str, Any] | list[Any] | str:
507
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
452
508
  """Instruct the device to update its firmware.
453
509
 
454
510
  You can either:
@@ -461,10 +517,16 @@ class CommandsMixin:
461
517
  raise UnsupportedFeature
462
518
 
463
519
  if firmware_bytes is not None and firmware_url is not None:
520
+ _LOGGER.error("Cannot specify both firmware_bytes and firmware_url")
464
521
  raise ValueError("Cannot specify both firmware_bytes and firmware_url")
465
522
 
523
+ if firmware_bytes is not None and len(firmware_bytes) == 0:
524
+ _LOGGER.error("Empty firmware bytes provided")
525
+ raise ValueError("Empty firmware bytes provided")
526
+
466
527
  if firmware_url is not None:
467
528
  if not isinstance(firmware_url, str) or not firmware_url.strip():
529
+ _LOGGER.error("Invalid firmware_url: %s", firmware_url)
468
530
  raise ValueError("Invalid firmware_url")
469
531
 
470
532
  url = f"{self.url}update"
@@ -485,13 +547,20 @@ class CommandsMixin:
485
547
  response = await self.process_request(
486
548
  url=url, method="post", rapi=form_data
487
549
  )
550
+ _LOGGER.debug("Firmware upload request completed. Response: %s", response)
488
551
  self._flag_ota_if_started(response)
489
552
  return response
490
553
 
491
554
  # 2. Resolve URL from GitHub if not specified
492
555
  if firmware_url is None:
556
+ _LOGGER.debug(
557
+ "No firmware URL provided. Resolving latest matching firmware from GitHub."
558
+ )
493
559
  check_result = await self.firmware_check()
494
560
  if not check_result or not check_result.get("browser_download_url"):
561
+ _LOGGER.error(
562
+ "Could not resolve latest firmware download URL from GitHub."
563
+ )
495
564
  raise RuntimeError(
496
565
  "Could not resolve latest firmware download URL from GitHub."
497
566
  )
@@ -503,6 +572,7 @@ class CommandsMixin:
503
572
  "Requesting OpenEVSE to download and update from: %s", firmware_url
504
573
  )
505
574
  response = await self.process_request(url=url, method="post", data=data)
575
+ _LOGGER.debug("Firmware update request completed. Response: %s", response)
506
576
  self._flag_ota_if_started(response)
507
577
  return response
508
578
 
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,9 +1,13 @@
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
 
@@ -28,12 +32,12 @@ class OpenEVSEWebsocket:
28
32
 
29
33
  def __init__(
30
34
  self,
31
- server,
32
- callback,
33
- user=None,
34
- password=None,
35
+ server: str,
36
+ callback: Callable[[str, Any, Any], Any] | None,
37
+ user: str | None = None,
38
+ password: str | None = None,
35
39
  session: aiohttp.ClientSession | None = None,
36
- ):
40
+ ) -> None:
37
41
  """Initialize a OpenEVSEWebsocket instance."""
38
42
  self.session = session
39
43
  self._session_external = session is not None
@@ -43,20 +47,20 @@ class OpenEVSEWebsocket:
43
47
  self.callback = callback
44
48
  self._state = STATE_DISCONNECTED
45
49
  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()
50
+ self._error_reason: Any = None
51
+ self._client: aiohttp.ClientWebSocketResponse | None = None
52
+ self._ping: datetime.datetime | None = None
53
+ self._pong: datetime.datetime | None = None
54
+ self._tasks: set[asyncio.Task[Any]] = set()
51
55
  self._listener_loop: asyncio.AbstractEventLoop | None = None
52
56
 
53
57
  @property
54
- def state(self):
58
+ def state(self) -> str:
55
59
  """Return the current state."""
56
60
  return self._state
57
61
 
58
62
  @state.setter
59
- def state(self, value):
63
+ def state(self, value: str) -> None:
60
64
  """Setter that schedules the callback."""
61
65
  self._state = value
62
66
  _LOGGER.debug("Websocket %s", value)
@@ -76,21 +80,16 @@ class OpenEVSEWebsocket:
76
80
  if self._listener_loop:
77
81
  self._listener_loop.call_soon_threadsafe(self._schedule_task, coro)
78
82
  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)
83
+ task = asyncio.ensure_future(coro)
84
+ self._tasks.add(task)
85
+ task.add_done_callback(self._tasks.discard)
87
86
  except RuntimeError:
88
87
  _LOGGER.error("Failed to schedule callback from sync context: %s", coro)
89
88
  if hasattr(coro, "close"):
90
89
  coro.close()
91
90
  self._error_reason = None
92
91
 
93
- def _schedule_task(self, coro):
92
+ def _schedule_task(self, coro: Awaitable[Any]) -> None:
94
93
  """Schedule a task from a thread-safe context."""
95
94
  try:
96
95
  task = asyncio.ensure_future(coro)
@@ -103,7 +102,7 @@ class OpenEVSEWebsocket:
103
102
  if hasattr(coro, "close"):
104
103
  coro.close()
105
104
 
106
- async def _set_state(self, value):
105
+ async def _set_state(self, value: str) -> None:
107
106
  """Async helper to set the state and await the callback."""
108
107
  self._state = value
109
108
  _LOGGER.debug("Websocket %s", value)
@@ -114,11 +113,11 @@ class OpenEVSEWebsocket:
114
113
  self._error_reason = None
115
114
 
116
115
  @staticmethod
117
- def _get_uri(server):
116
+ def _get_uri(server: str) -> str:
118
117
  """Generate the websocket URI."""
119
118
  return server[: server.rfind("/")].replace("http", "ws") + "/ws"
120
119
 
121
- async def running(self):
120
+ async def running(self) -> None:
122
121
  """Open a persistent websocket connection and act on events."""
123
122
  await self._ensure_session()
124
123
  await self._set_state(STATE_STARTING)
@@ -128,6 +127,8 @@ class OpenEVSEWebsocket:
128
127
  auth = aiohttp.BasicAuth(self._user, self._password)
129
128
 
130
129
  try:
130
+ # Narrow type for mypy since _ensure_session sets self.session
131
+ assert self.session is not None
131
132
  async with self.session.ws_connect(
132
133
  self.uri,
133
134
  heartbeat=15,
@@ -156,7 +157,9 @@ class OpenEVSEWebsocket:
156
157
  await self._client.close()
157
158
  self._client = None
158
159
 
159
- async def _handle_messages(self, ws_client):
160
+ async def _handle_messages(
161
+ self, ws_client: aiohttp.ClientWebSocketResponse
162
+ ) -> None:
160
163
  """Handle incoming websocket messages."""
161
164
  async for message in ws_client:
162
165
  if self.state == STATE_STOPPED:
@@ -183,7 +186,7 @@ class OpenEVSEWebsocket:
183
186
  _LOGGER.error("Websocket error")
184
187
  break
185
188
 
186
- async def _handle_response_error(self, error):
189
+ async def _handle_response_error(self, error: aiohttp.ClientResponseError) -> None:
187
190
  """Handle ClientResponseError."""
188
191
  if error.status == 401:
189
192
  _LOGGER.error("Credentials rejected: %s", error)
@@ -193,7 +196,7 @@ class OpenEVSEWebsocket:
193
196
  self._error_reason = error
194
197
  await self._set_state(STATE_STOPPED)
195
198
 
196
- async def _handle_connection_error(self, error):
199
+ async def _handle_connection_error(self, error: BaseException) -> None:
197
200
  """Handle connection errors."""
198
201
  self.failed_attempts += 1
199
202
  if self.failed_attempts > MAX_FAILED_ATTEMPTS:
@@ -209,7 +212,7 @@ class OpenEVSEWebsocket:
209
212
  await self._set_state(STATE_DISCONNECTED)
210
213
  await asyncio.sleep(retry_delay)
211
214
 
212
- async def listen(self):
215
+ async def listen(self) -> None:
213
216
  """Start the listening websocket."""
214
217
  await self._ensure_session()
215
218
  self.failed_attempts = 0
@@ -220,13 +223,13 @@ class OpenEVSEWebsocket:
220
223
  finally:
221
224
  self._listener_loop = None
222
225
 
223
- async def _ensure_session(self):
226
+ async def _ensure_session(self) -> None:
224
227
  """Ensure aiohttp.ClientSession exists."""
225
228
  if self.session is None:
226
229
  self.session = aiohttp.ClientSession()
227
230
  self._session_external = False
228
231
 
229
- async def close(self):
232
+ async def close(self) -> None:
230
233
  """Close the listening websocket."""
231
234
  await self._set_state(STATE_STOPPED)
232
235
 
@@ -244,7 +247,7 @@ class OpenEVSEWebsocket:
244
247
  await self.session.close()
245
248
  self.session = None
246
249
 
247
- async def keepalive(self):
250
+ async def keepalive(self) -> None:
248
251
  """Send ping requests to websocket."""
249
252
  if self._ping and self._pong:
250
253
  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.2
3
+ Version: 0.4.4
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
@@ -97,7 +97,7 @@ if __name__ == "__main__":
97
97
  | `/config` | GET, POST | ✅ | System and WiFi configuration |
98
98
  | `/override` | GET, POST, PATCH, DELETE | ✅ | Manual charging overrides & current limits |
99
99
  | `/claims` | GET, POST, DELETE | ✅ | Client-based charging claims |
100
- | `/schedule` | GET, POST | | Charging schedule management |
100
+ | `/schedule` | GET, POST | ⚠️ | Charging schedule management (Retrieval only) |
101
101
  | `/limit` | GET, POST, DELETE | ✅ | Charge limits (Time, Energy, SoC) |
102
102
  | `/shaper` | POST | ✅ | Grid shaper control (v4.0.0+) |
103
103
  | `/restart` | POST | ✅ | Reboot WiFi gateway or EVSE module |
@@ -111,7 +111,7 @@ if __name__ == "__main__":
111
111
  | `/tesla` | GET | ❌ | Tesla vehicle integration |
112
112
  | `/certificates`| GET, POST, DELETE | ❌ | SSL/TLS certificate management |
113
113
  | `/schedule/plan`| GET | ❌ | Schedule planning and optimization |
114
- | `/update` | POST | | Firmware update interface |
114
+ | `/update` | POST | | Firmware update interface |
115
115
  | `/rfid/add` | POST | ❌ | RFID tag management |
116
116
 
117
117
  ✅ = 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=8wd56AXEkEanRFWUPkmSDPUY8Zr7oHuNpg3_89m9iAM,18998
4
+ openevsehttp/commands.py,sha256=X40x1UaXVfPEiquw7mJGSVA3AbO9gQ4Ay0TLm9i1Iqk,27264
5
+ openevsehttp/const.py,sha256=y-2hGv_PCal_-VCSGC7IIyzQYtfeVdq3MjOhBWIdZvc,1440
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=TIMWzkRDs_9EUm3HAOaLWmZEX72BqHPhfDb9aIkz3uY,10409
13
+ python_openevse_http-0.4.4.dist-info/licenses/LICENSE,sha256=hSB6TOQ7rmwSGb6XzqRjDGMvmUj5_GlacqQin3tegtA,11341
14
+ python_openevse_http-0.4.4.dist-info/METADATA,sha256=RNnswPvgDpWIY5ehHia-XDxl4EGhZe1yIRquF0ZQP8M,4383
15
+ python_openevse_http-0.4.4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
16
+ python_openevse_http-0.4.4.dist-info/top_level.txt,sha256=u8RUkoEIE33Cjn6gmqiEoVpZ0VZ59WJ3FXBwwOg0CPE,13
17
+ python_openevse_http-0.4.4.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=XJO_FPdkzqBW7AU4VvBGktZ84TxRQ3a358mNAkqXQRw,24151
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.2.dist-info/licenses/LICENSE,sha256=hSB6TOQ7rmwSGb6XzqRjDGMvmUj5_GlacqQin3tegtA,11341
13
- python_openevse_http-0.4.2.dist-info/METADATA,sha256=gK5gBUi_Jy8Zza4bAxOjZ51_GtLY0ILses5rYUEJBT8,4363
14
- python_openevse_http-0.4.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
15
- python_openevse_http-0.4.2.dist-info/top_level.txt,sha256=u8RUkoEIE33Cjn6gmqiEoVpZ0VZ59WJ3FXBwwOg0CPE,13
16
- python_openevse_http-0.4.2.dist-info/RECORD,,