python-openevse-http 0.2.6__tar.gz → 0.3.0b0__tar.gz

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.
Files changed (36) hide show
  1. {python_openevse_http-0.2.6 → python_openevse_http-0.3.0b0}/PKG-INFO +1 -1
  2. python_openevse_http-0.3.0b0/openevsehttp/__init__.py +58 -0
  3. python_openevse_http-0.3.0b0/openevsehttp/__main__.py +4 -0
  4. python_openevse_http-0.3.0b0/openevsehttp/client.py +493 -0
  5. python_openevse_http-0.3.0b0/openevsehttp/commands.py +493 -0
  6. python_openevse_http-0.3.0b0/openevsehttp/const.py +62 -0
  7. python_openevse_http-0.3.0b0/openevsehttp/managers.py +157 -0
  8. python_openevse_http-0.3.0b0/openevsehttp/properties.py +528 -0
  9. python_openevse_http-0.3.0b0/openevsehttp/sensors.py +137 -0
  10. python_openevse_http-0.3.0b0/openevsehttp/websocket.py +275 -0
  11. {python_openevse_http-0.2.6 → python_openevse_http-0.3.0b0}/python_openevse_http.egg-info/PKG-INFO +1 -1
  12. {python_openevse_http-0.2.6 → python_openevse_http-0.3.0b0}/python_openevse_http.egg-info/SOURCES.txt +11 -1
  13. {python_openevse_http-0.2.6 → python_openevse_http-0.3.0b0}/setup.py +1 -1
  14. python_openevse_http-0.3.0b0/tests/test_client.py +1653 -0
  15. python_openevse_http-0.3.0b0/tests/test_commands.py +994 -0
  16. {python_openevse_http-0.2.6 → python_openevse_http-0.3.0b0}/tests/test_external_session.py +17 -6
  17. python_openevse_http-0.3.0b0/tests/test_managers.py +356 -0
  18. python_openevse_http-0.3.0b0/tests/test_mixins.py +58 -0
  19. python_openevse_http-0.3.0b0/tests/test_properties.py +1215 -0
  20. python_openevse_http-0.3.0b0/tests/test_sensors.py +154 -0
  21. {python_openevse_http-0.2.6 → python_openevse_http-0.3.0b0}/tests/test_websocket.py +253 -23
  22. python_openevse_http-0.2.6/openevsehttp/__init__.py +0 -1
  23. python_openevse_http-0.2.6/openevsehttp/__main__.py +0 -1394
  24. python_openevse_http-0.2.6/openevsehttp/const.py +0 -18
  25. python_openevse_http-0.2.6/openevsehttp/websocket.py +0 -196
  26. python_openevse_http-0.2.6/tests/test_main.py +0 -3268
  27. {python_openevse_http-0.2.6 → python_openevse_http-0.3.0b0}/LICENSE +0 -0
  28. {python_openevse_http-0.2.6 → python_openevse_http-0.3.0b0}/README.md +0 -0
  29. {python_openevse_http-0.2.6 → python_openevse_http-0.3.0b0}/openevsehttp/exceptions.py +0 -0
  30. {python_openevse_http-0.2.6 → python_openevse_http-0.3.0b0}/pyproject.toml +0 -0
  31. {python_openevse_http-0.2.6 → python_openevse_http-0.3.0b0}/python_openevse_http.egg-info/dependency_links.txt +0 -0
  32. {python_openevse_http-0.2.6 → python_openevse_http-0.3.0b0}/python_openevse_http.egg-info/not-zip-safe +0 -0
  33. {python_openevse_http-0.2.6 → python_openevse_http-0.3.0b0}/python_openevse_http.egg-info/requires.txt +0 -0
  34. {python_openevse_http-0.2.6 → python_openevse_http-0.3.0b0}/python_openevse_http.egg-info/top_level.txt +0 -0
  35. {python_openevse_http-0.2.6 → python_openevse_http-0.3.0b0}/setup.cfg +0 -0
  36. {python_openevse_http-0.2.6 → python_openevse_http-0.3.0b0}/tests/test_main_edge_cases.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python_openevse_http
3
- Version: 0.2.6
3
+ Version: 0.3.0b0
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
@@ -0,0 +1,58 @@
1
+ """Provide a package for python-openevse-http."""
2
+
3
+ # ruff: noqa: F401
4
+ from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError
5
+
6
+ from .client import (
7
+ OpenEVSE,
8
+ )
9
+ from .const import (
10
+ ERROR_TIMEOUT,
11
+ INFO_LOOP_RUNNING,
12
+ UPDATE_TRIGGERS,
13
+ divert_mode,
14
+ states,
15
+ )
16
+ from .exceptions import (
17
+ AlreadyListening,
18
+ AuthenticationError,
19
+ InvalidType,
20
+ MissingMethod,
21
+ MissingSerial,
22
+ ParseJSONError,
23
+ UnknownError,
24
+ UnsupportedFeature,
25
+ )
26
+ from .websocket import (
27
+ SIGNAL_CONNECTION_STATE,
28
+ STATE_CONNECTED,
29
+ STATE_DISCONNECTED,
30
+ STATE_STARTING,
31
+ STATE_STOPPED,
32
+ OpenEVSEWebsocket,
33
+ )
34
+
35
+ __all__ = [
36
+ "ERROR_TIMEOUT",
37
+ "INFO_LOOP_RUNNING",
38
+ "SIGNAL_CONNECTION_STATE",
39
+ "STATE_CONNECTED",
40
+ "STATE_DISCONNECTED",
41
+ "STATE_STARTING",
42
+ "STATE_STOPPED",
43
+ "UPDATE_TRIGGERS",
44
+ "AlreadyListening",
45
+ "AuthenticationError",
46
+ "ContentTypeError",
47
+ "InvalidType",
48
+ "MissingMethod",
49
+ "MissingSerial",
50
+ "OpenEVSE",
51
+ "OpenEVSEWebsocket",
52
+ "ParseJSONError",
53
+ "ServerTimeoutError",
54
+ "UnknownError",
55
+ "UnsupportedFeature",
56
+ "divert_mode",
57
+ "states",
58
+ ]
@@ -0,0 +1,4 @@
1
+ """Backward-compatibility shim — import everything from the package root."""
2
+
3
+ # ruff: noqa: F401, F403
4
+ from openevsehttp import * # noqa: F403
@@ -0,0 +1,493 @@
1
+ """Core client class for python-openevse-http."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import json
8
+ import logging
9
+ import re
10
+ import threading
11
+ from collections.abc import Callable, Mapping
12
+ from typing import Any
13
+
14
+ import aiohttp # type: ignore
15
+ from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError
16
+ from awesomeversion import AwesomeVersion
17
+ from awesomeversion.exceptions import AwesomeVersionCompareException
18
+
19
+ from .commands import CommandsMixin
20
+ from .const import (
21
+ ERROR_TIMEOUT,
22
+ UPDATE_TRIGGERS,
23
+ )
24
+ from .exceptions import (
25
+ AlreadyListening,
26
+ AuthenticationError,
27
+ MissingMethod,
28
+ MissingSerial,
29
+ ParseJSONError,
30
+ )
31
+ from .managers import ManagersMixin
32
+ from .properties import PropertiesMixin
33
+ from .sensors import SensorsMixin
34
+ from .websocket import (
35
+ SIGNAL_CONNECTION_STATE,
36
+ STATE_CONNECTED,
37
+ STATE_DISCONNECTED,
38
+ STATE_STOPPED,
39
+ OpenEVSEWebsocket,
40
+ )
41
+
42
+ _LOGGER = logging.getLogger(__name__)
43
+
44
+
45
+ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
46
+ """Represent an OpenEVSE charger."""
47
+
48
+ def __init__(
49
+ self,
50
+ host: str,
51
+ user: str = "",
52
+ pwd: str = "",
53
+ session: aiohttp.ClientSession | None = None,
54
+ ) -> None:
55
+ """Connect to an OpenEVSE charger equipped with wifi or ethernet."""
56
+ self._user = user
57
+ self._pwd = pwd
58
+ self.url = f"http://{host}/"
59
+ self._status: dict = {}
60
+ self._config: dict = {}
61
+ self._override = None
62
+ self._ws_listening = False
63
+ self.websocket: OpenEVSEWebsocket | None = None
64
+ self.callback: Callable | None = None
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
68
+ self._owns_loop = False
69
+ self._loop_thread: threading.Thread | None = None
70
+ self._session = session
71
+ self._session_external = session is not None
72
+
73
+ async def process_request(
74
+ self,
75
+ url: str,
76
+ method: str = "",
77
+ data: Any = None,
78
+ rapi: Any = None,
79
+ ) -> Mapping[str, Any] | list[Any] | str:
80
+ """Return result of processed HTTP request."""
81
+ auth = None
82
+ allowed_methods = ["get", "post", "put", "delete", "patch", "head", "options"]
83
+ if method not in allowed_methods:
84
+ raise MissingMethod
85
+
86
+ if self._user and self._pwd:
87
+ auth = aiohttp.BasicAuth(self._user, self._pwd)
88
+
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
+ )
99
+
100
+ def _normalize_response(self, response: Any) -> dict[str, Any] | list[Any]:
101
+ """Normalize response to a dict or list."""
102
+ if isinstance(response, dict | list):
103
+ return response
104
+ _LOGGER.debug("Normalizing non-json response: %s", response)
105
+ return {"msg": response}
106
+
107
+ async def _process_request_with_session(
108
+ self,
109
+ session: aiohttp.ClientSession,
110
+ url: str,
111
+ method: str,
112
+ data: Any,
113
+ rapi: Any,
114
+ auth: Any,
115
+ ) -> Mapping[str, Any] | list[Any] | str:
116
+ """Process a request with a given session."""
117
+ if not hasattr(session, method):
118
+ raise MissingMethod
119
+ http_method = getattr(session, method)
120
+ _LOGGER.debug(
121
+ "Connecting to %s with data: %s rapi: %s using method %s",
122
+ url,
123
+ data,
124
+ rapi,
125
+ method,
126
+ )
127
+ try:
128
+ kwargs = {"data": rapi, "auth": auth}
129
+ if data is not None:
130
+ kwargs["json"] = data
131
+ async with http_method(url, **kwargs) as resp:
132
+ try:
133
+ message = await resp.text()
134
+ except UnicodeDecodeError:
135
+ _LOGGER.debug("Decoding error")
136
+ message = await resp.read()
137
+ message = message.decode(errors="replace")
138
+
139
+ try:
140
+ message = json.loads(message)
141
+ except ValueError:
142
+ _LOGGER.warning("Non JSON response: %s", message)
143
+
144
+ 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"])
149
+ else:
150
+ _LOGGER.error("Error 400: %s", message)
151
+ raise ParseJSONError
152
+ if resp.status == 401:
153
+ _LOGGER.error("Authentication error: %s", message)
154
+ raise AuthenticationError
155
+ if resp.status in [404, 405, 500]:
156
+ _LOGGER.warning("%s", message)
157
+
158
+ if (
159
+ method.lower() != "get"
160
+ and isinstance(message, dict)
161
+ and any(key in message for key in UPDATE_TRIGGERS)
162
+ ):
163
+ await self.update()
164
+ return message
165
+
166
+ except (TimeoutError, ServerTimeoutError):
167
+ _LOGGER.error("%s: %s", ERROR_TIMEOUT, url)
168
+ raise
169
+ except ContentTypeError as err:
170
+ _LOGGER.error("Content error: %s", err.message)
171
+ raise
172
+
173
+ async def send_command(self, command: str) -> tuple:
174
+ """Send a RAPI command to the charger and parses the response."""
175
+ url = f"{self.url}r"
176
+ data = {"json": 1, "rapi": command}
177
+
178
+ _LOGGER.debug("Posting data: %s to %s", command, url)
179
+ value = await self.process_request(url=url, method="post", rapi=data)
180
+ if not isinstance(value, Mapping) or "ret" not in value or "cmd" not in value:
181
+ if isinstance(value, Mapping) and "msg" in value:
182
+ return (False, value["msg"])
183
+ return (False, "")
184
+ return (value["cmd"], value["ret"])
185
+
186
+ async def update(self) -> None:
187
+ """Update the values."""
188
+ # TODO: add addiontal endpoints to update
189
+ urls = [f"{self.url}config"]
190
+
191
+ if not self._ws_listening:
192
+ urls = [f"{self.url}status", f"{self.url}config"]
193
+
194
+ for url in urls:
195
+ _LOGGER.debug("Updating data from %s", url)
196
+ response = await self.process_request(url, method="get")
197
+ if "/status" in url:
198
+ if isinstance(response, Mapping) and "error" not in response:
199
+ self._status = dict(response)
200
+ _LOGGER.debug("Status update: %s", self._status)
201
+ elif isinstance(response, Mapping):
202
+ _LOGGER.warning(
203
+ "Error in /status response: %s", response.get("error")
204
+ )
205
+ else:
206
+ _LOGGER.warning(
207
+ "Received non-JSON response from /status: %s", response
208
+ )
209
+
210
+ else:
211
+ if isinstance(response, Mapping) and "error" not in response:
212
+ self._config = dict(response)
213
+ _LOGGER.debug("Config update: %s", self._config)
214
+ elif isinstance(response, Mapping):
215
+ _LOGGER.warning(
216
+ "Error in /config response: %s", response.get("error")
217
+ )
218
+ else:
219
+ _LOGGER.warning(
220
+ "Received non-JSON response from /config: %s", response
221
+ )
222
+
223
+ async def test_and_get(self) -> dict:
224
+ """Test connection.
225
+
226
+ Return model serial number as dict
227
+ """
228
+ url = f"{self.url}config"
229
+ data = {}
230
+
231
+ response = await self.process_request(url, method="get")
232
+ if not isinstance(response, Mapping):
233
+ _LOGGER.debug("Invalid response from config: %s", response)
234
+ raise MissingSerial
235
+
236
+ if "wifi_serial" in response:
237
+ serial = response["wifi_serial"]
238
+ else:
239
+ _LOGGER.debug("Older firmware detected, missing serial.")
240
+ raise MissingSerial
241
+ if "buildenv" in response:
242
+ model = response["buildenv"]
243
+ else:
244
+ model = "unknown"
245
+
246
+ data = {"serial": serial, "model": model}
247
+ return data
248
+
249
+ def ws_start(self) -> None:
250
+ """Start the websocket listener."""
251
+ if self.websocket and self.websocket.state != STATE_STOPPED:
252
+ raise AlreadyListening
253
+
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
271
+
272
+ 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
+
277
+ self._start_listening()
278
+
279
+ def _start_listening(self):
280
+ """Start the websocket listener."""
281
+ if not self._loop:
282
+ try:
283
+ _LOGGER.debug("Attempting to find running loop...")
284
+ self._loop = asyncio.get_running_loop()
285
+ except RuntimeError:
286
+ self._loop = asyncio.new_event_loop()
287
+ self._owns_loop = True
288
+ _LOGGER.debug("Using new event loop...")
289
+
290
+ if not self._ws_listening and self.websocket is not None:
291
+ _LOGGER.debug("Setting up websocket tasks...")
292
+ self._ws_listen_task = self._loop.create_task(self.websocket.listen())
293
+ self._ws_keepalive_task = self._loop.create_task(
294
+ self.repeat(300, self.websocket.keepalive)
295
+ )
296
+
297
+ if self._owns_loop:
298
+ self._loop_thread = threading.Thread(
299
+ target=self._loop.run_forever, daemon=True
300
+ )
301
+ self._loop_thread.start()
302
+
303
+ async def _update_status(self, msgtype, data, error):
304
+ """Update data from websocket listener."""
305
+ if msgtype == SIGNAL_CONNECTION_STATE:
306
+ uri = self.websocket.uri if self.websocket else "Unknown"
307
+ if data == STATE_CONNECTED:
308
+ _LOGGER.debug("Websocket to %s successful", uri)
309
+ self._ws_listening = True
310
+ elif data == STATE_DISCONNECTED:
311
+ _LOGGER.debug(
312
+ "Websocket to %s disconnected, retrying",
313
+ uri,
314
+ )
315
+ _LOGGER.debug("Disconnect message: %s", error)
316
+ self._ws_listening = False
317
+
318
+ # 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
+ )
326
+ self._ws_listening = False
327
+
328
+ elif msgtype == "data":
329
+ _LOGGER.debug("Websocket data: %s", data)
330
+ if not isinstance(data, Mapping):
331
+ _LOGGER.warning("Received non-Mapping websocket data: %s", data)
332
+ return
333
+
334
+ keys = data.keys()
335
+ if "wh" in keys:
336
+ data["watthour"] = data.pop("wh")
337
+ # TODO: update specific endpoints based on _version prefix
338
+ if any(key in keys for key in UPDATE_TRIGGERS):
339
+ await self.update()
340
+ self._status.update(data)
341
+
342
+ if self.callback is not None:
343
+ result = self.callback() # pylint: disable=not-callable
344
+ if inspect.isawaitable(result):
345
+ await result
346
+
347
+ async def _shutdown(self):
348
+ """Shutdown the websocket and tasks on the listener loop."""
349
+ tasks = []
350
+ if self._ws_keepalive_task:
351
+ self._ws_keepalive_task.cancel()
352
+ tasks.append(self._ws_keepalive_task)
353
+ if self._ws_listen_task:
354
+ self._ws_listen_task.cancel()
355
+ tasks.append(self._ws_listen_task)
356
+
357
+ if self.websocket:
358
+ # Close the websocket (this cancels running() internal tasks)
359
+ await self.websocket.close()
360
+
361
+ # Cancel any remaining callback tasks
362
+ for task in list(self.websocket._tasks):
363
+ if not task.done():
364
+ task.cancel()
365
+ tasks.append(task)
366
+ self.websocket._tasks.clear()
367
+
368
+ if tasks:
369
+ await asyncio.gather(
370
+ *(t for t in tasks if isinstance(t, asyncio.Future | asyncio.Task)),
371
+ return_exceptions=True,
372
+ )
373
+
374
+ if self.websocket:
375
+ self.websocket = None
376
+
377
+ self._ws_listen_task = None
378
+ self._ws_keepalive_task = None
379
+ if self._owns_loop and self._loop:
380
+ self._loop.stop()
381
+
382
+ async def ws_disconnect(self) -> None:
383
+ """Disconnect the websocket listener."""
384
+ self._ws_listening = False
385
+
386
+ if self._owns_loop and self._loop:
387
+ # Schedule shutdown coroutine on the loop thread
388
+ future = asyncio.run_coroutine_threadsafe(self._shutdown(), self._loop)
389
+ shutdown_succeeded = False
390
+ try:
391
+ # Wait for the shutdown to complete on the other loop
392
+ await asyncio.wait_for(asyncio.wrap_future(future), timeout=2.0)
393
+ shutdown_succeeded = True
394
+ except (TimeoutError, asyncio.CancelledError) as err:
395
+ _LOGGER.debug("Error during shutdown coroutine: %s", err)
396
+
397
+ if self._loop_thread:
398
+ await asyncio.to_thread(self._loop_thread.join, 2.0)
399
+ if not self._loop_thread.is_alive():
400
+ self._loop_thread = None
401
+
402
+ if shutdown_succeeded and self._loop_thread is None:
403
+ self._loop.close()
404
+ self._loop = None
405
+ self._owns_loop = False
406
+ else:
407
+ # Standard async disconnect for caller loop
408
+ await self._shutdown()
409
+
410
+ def is_coroutine_function(self, callback):
411
+ """Check if a callback is a coroutine function."""
412
+ return inspect.iscoroutinefunction(callback)
413
+
414
+ @property
415
+ def ws_state(self) -> Any | None:
416
+ """Return the status of the websocket listener."""
417
+ if self.websocket is None:
418
+ return STATE_STOPPED
419
+ return self.websocket.state
420
+
421
+ async def repeat(self, interval, func, *args, **kwargs):
422
+ """Run func every interval seconds.
423
+
424
+ If func has not finished before *interval*, will run again
425
+ immediately when the previous iteration finished.
426
+
427
+ *args and **kwargs are passed as the arguments to func.
428
+ """
429
+ while self.ws_state != STATE_STOPPED and self._ws_listening:
430
+ await asyncio.sleep(interval)
431
+ if self.ws_state == STATE_STOPPED or not self._ws_listening:
432
+ break
433
+ result = func(*args, **kwargs)
434
+ if inspect.isawaitable(result):
435
+ await result
436
+
437
+ def _version_check(self, min_version: str, max_version: str = "") -> bool:
438
+ """Return bool if minimum version is met."""
439
+ if "version" not in self._config:
440
+ # Throw warning if we can't find the version
441
+ _LOGGER.warning("Unable to find firmware version.")
442
+ return False
443
+ cutoff = AwesomeVersion(min_version)
444
+ current = ""
445
+ limit = ""
446
+ if max_version != "":
447
+ limit = AwesomeVersion(max_version)
448
+
449
+ firmware_filtered = None
450
+ firmware_search = re.search(r"\d+\.\d+\.\d+", self._config["version"])
451
+ if firmware_search:
452
+ firmware_filtered = firmware_search.group(0)
453
+
454
+ if firmware_filtered is None:
455
+ _LOGGER.warning(
456
+ "Non-standard versioning string: %s", self._config["version"]
457
+ )
458
+ _LOGGER.debug("Non-semver firmware version detected.")
459
+ return False
460
+
461
+ _LOGGER.debug("Detected firmware: %s", self._config["version"])
462
+ _LOGGER.debug("Filtered firmware: %s", firmware_filtered)
463
+
464
+ if "dev" in self._config["version"]:
465
+ value = self._config["version"]
466
+ _LOGGER.debug("Stripping 'dev' from version.")
467
+ value = value.split(".")
468
+ value = ".".join(value[0:3])
469
+ elif "master" in self._config["version"]:
470
+ value = "dev"
471
+ else:
472
+ value = firmware_filtered
473
+
474
+ current = AwesomeVersion(value)
475
+
476
+ if limit:
477
+ try:
478
+ if cutoff <= current < limit:
479
+ return True
480
+ except AwesomeVersionCompareException:
481
+ _LOGGER.debug("Non-semver firmware version detected.")
482
+ return False
483
+
484
+ try:
485
+ if current >= cutoff:
486
+ return True
487
+ except AwesomeVersionCompareException:
488
+ _LOGGER.debug("Non-semver firmware version detected.")
489
+ return False
490
+
491
+ def version_check(self, min_version: str, max_version: str = "") -> bool:
492
+ """Unprotected function call for version checking."""
493
+ return self._version_check(min_version=min_version, max_version=max_version)