Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 64 additions & 43 deletions openevsehttp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
import json
import logging
import threading
from collections.abc import Callable, Mapping
from collections.abc import Callable, Mapping, MutableMapping
from typing import Any

import aiohttp # type: ignore
import aiohttp
from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError
from awesomeversion import AwesomeVersion
from awesomeversion.exceptions import AwesomeVersionCompareException
Expand Down Expand Up @@ -56,15 +56,15 @@ def __init__(
self._user = user
self._pwd = pwd
self.url = f"http://{host}/"
self._status: dict = {}
self._config: dict = {}
self._override = None
self._status: dict[str, Any] = {}
self._config: dict[str, Any] = {}
self._override: Any = None
self._ws_listening = False
self.websocket: OpenEVSEWebsocket | None = None
self.callback: Callable | None = None
self.callback: Callable[[], Any] | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._ws_listen_task: asyncio.Task | None = None
self._ws_keepalive_task: asyncio.Task | None = None
self._ws_listen_task: asyncio.Task[Any] | None = None
self._ws_keepalive_task: asyncio.Task[Any] | None = None
self._owns_loop = False
self._loop_thread: threading.Thread | None = None
self._session = session
Expand All @@ -76,7 +76,7 @@ async def process_request(
method: str = "",
data: Any = None,
rapi: Any = None,
) -> Mapping[str, Any] | list[Any] | str:
) -> Mapping[str, Any] | list[Any] | str | bool:
"""Return result of processed HTTP request."""
auth = None
allowed_methods = ["get", "post", "put", "delete", "patch", "head", "options"]
Expand Down Expand Up @@ -112,7 +112,7 @@ async def _process_request_with_session(
data: Any,
rapi: Any,
auth: Any,
) -> Mapping[str, Any] | list[Any] | str:
) -> Mapping[str, Any] | list[Any] | str | bool:
"""Process a request with a given session."""
if not hasattr(session, method):
raise MissingMethod
Expand All @@ -130,38 +130,51 @@ async def _process_request_with_session(
kwargs["json"] = data
async with http_method(url, **kwargs) as resp:
try:
message = await resp.text()
raw = await resp.text()
except UnicodeDecodeError:
_LOGGER.debug("Decoding error")
message = await resp.read()
message = message.decode(errors="replace")
raw = (await resp.read()).decode(errors="replace")

# JSON responses can sometimes be primitive values (like bools).
# If json.loads fails with ValueError (e.g. non-JSON text/html),
# we fall back to treating the raw response as a string.
response_content: Mapping[str, Any] | list[Any] | str | bool = raw
try:
message = json.loads(message)
response_content = json.loads(raw)
except ValueError:
_LOGGER.debug("Non JSON response: %s", message)
_LOGGER.debug("Non JSON response: %s", raw)
if not isinstance(response_content, dict | list | str | bool):
_LOGGER.error(
"Unexpected JSON primitive response from %s: %r",
url,
response_content,
)
raise ParseJSONError

if resp.status == 400:
if isinstance(message, dict) and "msg" in message:
_LOGGER.error("Error 400: %s", message["msg"])
elif isinstance(message, dict) and "error" in message:
_LOGGER.error("Error 400: %s", message["error"])
if isinstance(response_content, dict) and "msg" in response_content:
_LOGGER.error("Error 400: %s", response_content["msg"])
elif (
isinstance(response_content, dict)
and "error" in response_content
):
_LOGGER.error("Error 400: %s", response_content["error"])
else:
_LOGGER.error("Error 400: %s", message)
_LOGGER.error("Error 400: %s", response_content)
raise ParseJSONError
if resp.status == 401:
_LOGGER.error("Authentication error: %s", message)
_LOGGER.error("Authentication error: %s", response_content)
raise AuthenticationError
if resp.status in [404, 405, 500]:
_LOGGER.warning("%s", message)
_LOGGER.warning("%s", response_content)

if (
method.lower() != "get"
and isinstance(message, dict)
and any(key in message for key in UPDATE_TRIGGERS)
and isinstance(response_content, dict)
and any(key in response_content for key in UPDATE_TRIGGERS)
):
await self.update()
return message
return response_content

except (TimeoutError, ServerTimeoutError):
_LOGGER.error("%s: %s", ERROR_TIMEOUT, url)
Expand All @@ -170,7 +183,7 @@ async def _process_request_with_session(
_LOGGER.error("Content error: %s", err.message)
raise

async def send_command(self, command: str) -> tuple:
async def send_command(self, command: str) -> tuple[Any, Any]:
"""Send a RAPI command to the charger and parses the response."""
url = f"{self.url}r"
data = {"json": 1, "rapi": command}
Expand Down Expand Up @@ -220,13 +233,13 @@ async def update(self, force_status: bool = False) -> None:
"Received non-JSON response from /config: %s", response
)

async def test_and_get(self) -> dict:
async def test_and_get(self) -> dict[str, Any]:
"""Test connection.

Return model serial number as dict
"""
url = f"{self.url}config"
data = {}
data: dict[str, Any] = {}

response = await self.process_request(url, method="get")
if not isinstance(response, Mapping):
Expand Down Expand Up @@ -276,7 +289,7 @@ def ws_start(self) -> None:

self._start_listening()

def _start_listening(self):
def _start_listening(self) -> None:
"""Start the websocket listener."""
if not self._loop:
try:
Expand All @@ -300,7 +313,7 @@ def _start_listening(self):
)
self._loop_thread.start()

async def _update_status(self, msgtype, data, error):
async def _update_status(self, msgtype: str, data: Any, error: Any) -> None:
"""Update data from websocket listener."""
if msgtype == SIGNAL_CONNECTION_STATE:
uri = self.websocket.uri if self.websocket else "Unknown"
Expand All @@ -316,18 +329,18 @@ async def _update_status(self, msgtype, data, error):
self._ws_listening = False

# Stopped websockets without errors are expected during shutdown
# and ignored
elif data == STATE_STOPPED and error:
_LOGGER.debug(
"Websocket to %s failed, aborting [Error: %s]",
uri,
error,
)
elif data == STATE_STOPPED:
if error:
_LOGGER.debug(
"Websocket to %s failed, aborting [Error: %s]",
uri,
error,
)
self._ws_listening = False

elif msgtype == "data":
_LOGGER.debug("Websocket data: %s", data)
if not isinstance(data, Mapping):
if not isinstance(data, MutableMapping):
_LOGGER.warning("Received non-Mapping websocket data: %s", data)
return

Expand All @@ -354,7 +367,7 @@ async def _update_status(self, msgtype, data, error):
if inspect.isawaitable(result):
await result

async def _shutdown(self):
async def _shutdown(self) -> None:
"""Shutdown the websocket and tasks on the listener loop."""
tasks = []
if self._ws_keepalive_task:
Expand Down Expand Up @@ -417,7 +430,7 @@ async def ws_disconnect(self) -> None:
# Standard async disconnect for caller loop
await self._shutdown()

def is_coroutine_function(self, callback):
def is_coroutine_function(self, callback: Any) -> bool:
"""Check if a callback is a coroutine function."""
return inspect.iscoroutinefunction(callback)

Expand All @@ -428,18 +441,26 @@ def ws_state(self) -> Any | None:
return STATE_STOPPED
return self.websocket.state

async def repeat(self, interval, func, *args, **kwargs):
async def repeat(
self,
interval: float,
func: Callable[..., Any],
*args: Any,
**kwargs: Any,
) -> None:
"""Run func every interval seconds.

If func has not finished before *interval*, will run again
immediately when the previous iteration finished.

*args and **kwargs are passed as the arguments to func.
"""
while self.ws_state != STATE_STOPPED and self._ws_listening:
while self.ws_state != STATE_STOPPED:
await asyncio.sleep(interval)
if self.ws_state == STATE_STOPPED or not self._ws_listening:
if self.ws_state == STATE_STOPPED:
break
if not self._ws_listening:
continue
result = func(*args, **kwargs)
if inspect.isawaitable(result):
await result
Expand Down
16 changes: 8 additions & 8 deletions openevsehttp/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from collections.abc import Mapping
from typing import Any

import aiohttp # type: ignore
import aiohttp
from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError
from awesomeversion import AwesomeVersion
from awesomeversion.exceptions import AwesomeVersionCompareException
Expand All @@ -23,8 +23,8 @@ class CommandsMixin:
"""Mixin providing command methods for OpenEVSE."""

url: str
_status: dict
_config: dict
_status: dict[str, Any]
_config: dict[str, Any]
_session: Any

# These are defined in client.py
Expand All @@ -33,10 +33,10 @@ def _version_check(self, min_version: str, max_version: str = "") -> bool:

async def process_request(
self, url: str, method: str = "", data: Any = None, rapi: Any = None
) -> Mapping[str, Any] | list[Any] | str:
) -> Mapping[str, Any] | list[Any] | str | bool:
raise NotImplementedError

async def send_command(self, command: str) -> tuple:
async def send_command(self, command: str) -> tuple[Any, Any]:
raise NotImplementedError

async def update(self, force_status: bool = False) -> None:
Expand Down Expand Up @@ -360,7 +360,7 @@ async def restart_evse(self) -> None:
_LOGGER.debug("EVSE Restart response: %s", response)

# Firmware version
async def firmware_check(self) -> dict | None:
async def firmware_check(self) -> dict[str, Any] | None:
"""Return the latest firmware version."""
if "version" not in self._config:
# Throw warning if we can't find the version
Expand Down Expand Up @@ -403,7 +403,7 @@ async def firmware_check(self) -> dict | None:

async def _firmware_check_with_session(
self, session: aiohttp.ClientSession, url: str, method: str
) -> dict | None:
) -> dict[str, Any] | None:
"""Process a firmware check request with a given session."""
http_method = getattr(session, method)
_LOGGER.debug(
Expand Down Expand Up @@ -448,7 +448,7 @@ async def update_firmware(
firmware_url: str | None = None,
firmware_bytes: bytes | None = None,
filename: str = "firmware.bin",
) -> Mapping[str, Any] | list[Any] | str:
) -> Mapping[str, Any] | list[Any] | str | bool:
"""Instruct the device to update its firmware.

You can either:
Expand Down
2 changes: 1 addition & 1 deletion openevsehttp/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def _version_check(self, min_version: str, max_version: str = "") -> bool:

async def process_request(
self, url: str, method: str = "", data: Any = None, rapi: Any = None
) -> Mapping[str, Any] | list[Any] | str:
) -> Mapping[str, Any] | list[Any] | str | bool:
raise NotImplementedError

def _normalize_response(self, response: Any) -> dict[str, Any] | list[Any]:
Expand Down
Loading
Loading