python-openevse-http 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,137 @@
1
+ """Sensor data posting methods for the OpenEVSE charger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ from .const import BAT_LVL, BAT_RANGE, GRID, SOLAR, TTF, VOLTAGE
10
+ from .exceptions import UnsupportedFeature
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ class SensorsMixin:
16
+ """Mixin providing sensor data posting methods for OpenEVSE."""
17
+
18
+ url: str
19
+
20
+ # These are defined in client.py
21
+ def _version_check(self, min_version: str, max_version: str = "") -> bool:
22
+ raise NotImplementedError
23
+
24
+ async def process_request(
25
+ self, url: str, method: str = "", data: Any = None, rapi: Any = None
26
+ ) -> Mapping[str, Any] | list[Any] | str:
27
+ raise NotImplementedError
28
+
29
+ def _normalize_response(self, response: Any) -> dict[str, Any] | list[Any]:
30
+ """Normalize response to a dict or list."""
31
+ raise NotImplementedError
32
+
33
+ # HTTP Posting of grid voltage
34
+ async def grid_voltage(self, voltage: int | None = None) -> None:
35
+ """Send pushed sensor data to grid voltage."""
36
+ if not self._version_check("2.9.1"):
37
+ _LOGGER.debug("Feature not supported for older firmware.")
38
+ raise UnsupportedFeature
39
+
40
+ url = f"{self.url}status"
41
+ data = {}
42
+
43
+ if voltage is not None:
44
+ data[VOLTAGE] = voltage
45
+
46
+ if not data:
47
+ _LOGGER.info("No sensor data to send to device.")
48
+ else:
49
+ _LOGGER.debug("Posting voltage: %s", data)
50
+ response = await self.process_request(url=url, method="post", data=data)
51
+ _LOGGER.debug(
52
+ "Voltage posting response: %s", self._normalize_response(response)
53
+ )
54
+
55
+ # Self production HTTP Posting
56
+ async def self_production(
57
+ self,
58
+ grid: int | None = None,
59
+ solar: int | None = None,
60
+ invert: bool = True,
61
+ voltage: int | None = None,
62
+ ) -> None:
63
+ """Send pushed sensor data to self-production."""
64
+ if not self._version_check("2.9.1"):
65
+ _LOGGER.debug("Feature not supported for older firmware.")
66
+ raise UnsupportedFeature
67
+
68
+ # Invert the sensor -import/+export
69
+ if invert and grid is not None:
70
+ grid = grid * -1
71
+
72
+ url = f"{self.url}status"
73
+ data = {}
74
+
75
+ # Prefer grid sensor data
76
+ if grid is not None:
77
+ data[GRID] = grid
78
+ elif solar is not None:
79
+ data[SOLAR] = solar
80
+ if voltage is not None:
81
+ data[VOLTAGE] = voltage
82
+
83
+ if not data:
84
+ _LOGGER.info("No sensor data to send to device.")
85
+ else:
86
+ _LOGGER.debug("Posting self-production: %s", data)
87
+ response = await self.process_request(url=url, method="post", data=data)
88
+ _LOGGER.debug(
89
+ "Self-production response: %s", self._normalize_response(response)
90
+ )
91
+
92
+ # State of charge HTTP posting
93
+ async def soc(
94
+ self,
95
+ battery_level: int | None = None,
96
+ battery_range: int | None = None,
97
+ time_to_full: int | None = None,
98
+ voltage: int | None = None,
99
+ ) -> None:
100
+ """Send pushed sensor data to State of Charge."""
101
+ if not self._version_check("4.1.0"):
102
+ _LOGGER.debug("Feature not supported for older firmware.")
103
+ raise UnsupportedFeature
104
+
105
+ url = f"{self.url}status"
106
+ data = {}
107
+
108
+ # Build post data
109
+ if battery_level is not None:
110
+ data[BAT_LVL] = battery_level
111
+ if battery_range is not None:
112
+ data[BAT_RANGE] = battery_range
113
+ if time_to_full is not None:
114
+ data[TTF] = time_to_full
115
+ if voltage is not None:
116
+ data[VOLTAGE] = voltage
117
+
118
+ if not data:
119
+ _LOGGER.info("No SOC data to send to device.")
120
+ else:
121
+ _LOGGER.debug("Posting SOC data: %s", data)
122
+ response = await self.process_request(url=url, method="post", data=data)
123
+ _LOGGER.debug("SOC response: %s", self._normalize_response(response))
124
+
125
+ # Shaper HTTP Posting
126
+ async def set_shaper_live_pwr(self, power: int) -> None:
127
+ """Send pushed sensor data to shaper."""
128
+ if not self._version_check("4.0.0"):
129
+ _LOGGER.debug("Feature not supported for older firmware.")
130
+ raise UnsupportedFeature
131
+
132
+ url = f"{self.url}status"
133
+ data = {"shaper_live_pwr": power}
134
+
135
+ _LOGGER.debug("Posting shaper data: %s", data)
136
+ response = await self.process_request(url=url, method="post", data=data)
137
+ _LOGGER.debug("Shaper response: %s", self._normalize_response(response))
openevsehttp/utils.py ADDED
@@ -0,0 +1,29 @@
1
+ """Utility functions for python-openevse-http."""
2
+
3
+ import logging
4
+ import re
5
+
6
+ from awesomeversion import AwesomeVersion
7
+
8
+ _LOGGER = logging.getLogger(__name__)
9
+
10
+
11
+ def normalize_version(version: str) -> str:
12
+ """Normalize the version string to strip 'dev' tag."""
13
+ if "dev" in version:
14
+ _LOGGER.debug("Stripping 'dev' from version.")
15
+ value = version.split(".")
16
+ return ".".join(value[0:3])
17
+ return version
18
+
19
+
20
+ def get_awesome_version(version: str) -> AwesomeVersion:
21
+ """Parse and normalize the version string, returning an AwesomeVersion."""
22
+ if "master" in version:
23
+ version = "dev"
24
+ value = normalize_version(version)
25
+ if "dev" not in version:
26
+ firmware_search = re.search(r"\d+\.\d+\.\d+", value)
27
+ if firmware_search:
28
+ value = firmware_search.group(0)
29
+ return AwesomeVersion(value)
@@ -0,0 +1,275 @@
1
+ """Websocket class for OpenEVSE HTTP."""
2
+
3
+ import asyncio
4
+ import datetime
5
+ import inspect
6
+ import logging
7
+
8
+ import aiohttp
9
+
10
+ _LOGGER = logging.getLogger(__name__)
11
+
12
+ MAX_FAILED_ATTEMPTS = 5
13
+
14
+ ERROR_AUTH_FAILURE = "Authorization failure"
15
+ ERROR_TOO_MANY_RETRIES = "Too many retries"
16
+ ERROR_UNKNOWN = "Unknown"
17
+ ERROR_PING_TIMEOUT = "No pong reply"
18
+
19
+ SIGNAL_CONNECTION_STATE = "websocket_state"
20
+ STATE_CONNECTED = "connected"
21
+ STATE_DISCONNECTED = "disconnected"
22
+ STATE_STARTING = "starting"
23
+ STATE_STOPPED = "stopped"
24
+
25
+
26
+ class OpenEVSEWebsocket:
27
+ """Represent a websocket connection to a OpenEVSE charger."""
28
+
29
+ def __init__(
30
+ self,
31
+ server,
32
+ callback,
33
+ user=None,
34
+ password=None,
35
+ session: aiohttp.ClientSession | None = None,
36
+ ):
37
+ """Initialize a OpenEVSEWebsocket instance."""
38
+ self.session = session
39
+ self._session_external = session is not None
40
+ self.uri = self._get_uri(server)
41
+ self._user = user
42
+ self._password = password
43
+ self.callback = callback
44
+ self._state = STATE_DISCONNECTED
45
+ 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()
51
+ self._listener_loop: asyncio.AbstractEventLoop | None = None
52
+
53
+ @property
54
+ def state(self):
55
+ """Return the current state."""
56
+ return self._state
57
+
58
+ @state.setter
59
+ def state(self, value):
60
+ """Setter that schedules the callback."""
61
+ self._state = value
62
+ _LOGGER.debug("Websocket %s", value)
63
+
64
+ if not self.callback:
65
+ self._error_reason = None
66
+ return
67
+
68
+ # Prepare the coroutine or invoke the callback
69
+ coro = self.callback(SIGNAL_CONNECTION_STATE, value, self._error_reason)
70
+
71
+ if not inspect.isawaitable(coro):
72
+ self._error_reason = None
73
+ return
74
+
75
+ try:
76
+ if self._listener_loop:
77
+ self._listener_loop.call_soon_threadsafe(self._schedule_task, coro)
78
+ 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
+ except RuntimeError:
88
+ _LOGGER.error("Failed to schedule callback from sync context: %s", coro)
89
+ if hasattr(coro, "close"):
90
+ coro.close()
91
+ self._error_reason = None
92
+
93
+ def _schedule_task(self, coro):
94
+ """Schedule a task from a thread-safe context."""
95
+ try:
96
+ task = asyncio.ensure_future(coro)
97
+ self._tasks.add(task)
98
+ task.add_done_callback(self._tasks.discard)
99
+ except RuntimeError:
100
+ _LOGGER.error("Failed to schedule callback task: %s", coro)
101
+ # If we still can't schedule it, we must at least close the coroutine
102
+ # to avoid RuntimeWarning: coroutine '...' was never awaited
103
+ if hasattr(coro, "close"):
104
+ coro.close()
105
+
106
+ async def _set_state(self, value):
107
+ """Async helper to set the state and await the callback."""
108
+ self._state = value
109
+ _LOGGER.debug("Websocket %s", value)
110
+ if self.callback:
111
+ result = self.callback(SIGNAL_CONNECTION_STATE, value, self._error_reason)
112
+ if inspect.isawaitable(result):
113
+ await result
114
+ self._error_reason = None
115
+
116
+ @staticmethod
117
+ def _get_uri(server):
118
+ """Generate the websocket URI."""
119
+ return server[: server.rfind("/")].replace("http", "ws") + "/ws"
120
+
121
+ async def running(self):
122
+ """Open a persistent websocket connection and act on events."""
123
+ await self._ensure_session()
124
+ await self._set_state(STATE_STARTING)
125
+ auth = None
126
+
127
+ if self._user and self._password:
128
+ auth = aiohttp.BasicAuth(self._user, self._password)
129
+
130
+ try:
131
+ async with self.session.ws_connect(
132
+ self.uri,
133
+ heartbeat=15,
134
+ auth=auth,
135
+ ) as ws_client:
136
+ self._client = ws_client
137
+ await self._set_state(STATE_CONNECTED)
138
+ self.failed_attempts = 0
139
+ await self._handle_messages(ws_client)
140
+
141
+ except aiohttp.ClientResponseError as error:
142
+ await self._handle_response_error(error)
143
+ except (aiohttp.ClientConnectionError, asyncio.TimeoutError) as error:
144
+ await self._handle_connection_error(error)
145
+ except Exception as error: # pylint: disable=broad-except
146
+ if self.state != STATE_STOPPED:
147
+ _LOGGER.exception("Unexpected exception occurred: %s", error)
148
+ self._error_reason = error
149
+ await self._set_state(STATE_STOPPED)
150
+ else:
151
+ if self.state != STATE_STOPPED:
152
+ await self._set_state(STATE_DISCONNECTED)
153
+ await asyncio.sleep(5)
154
+ finally:
155
+ if self._client is not None:
156
+ await self._client.close()
157
+ self._client = None
158
+
159
+ async def _handle_messages(self, ws_client):
160
+ """Handle incoming websocket messages."""
161
+ async for message in ws_client:
162
+ if self.state == STATE_STOPPED:
163
+ break
164
+
165
+ if message.type == aiohttp.WSMsgType.TEXT:
166
+ msg = message.json()
167
+ if isinstance(msg, dict) and "pong" in msg:
168
+ self._pong = datetime.datetime.now()
169
+ if len(msg) == 1:
170
+ # Pure pong frame, skip callback
171
+ continue
172
+
173
+ msgtype = "data"
174
+ if self.callback:
175
+ result = self.callback(msgtype, msg, None)
176
+ if inspect.isawaitable(result):
177
+ await result
178
+
179
+ elif message.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
180
+ if message.type == aiohttp.WSMsgType.CLOSED:
181
+ _LOGGER.warning("Websocket connection closed")
182
+ else:
183
+ _LOGGER.error("Websocket error")
184
+ break
185
+
186
+ async def _handle_response_error(self, error):
187
+ """Handle ClientResponseError."""
188
+ if error.status == 401:
189
+ _LOGGER.error("Credentials rejected: %s", error)
190
+ self._error_reason = ERROR_AUTH_FAILURE
191
+ else:
192
+ _LOGGER.error("Unexpected response received: %s", error)
193
+ self._error_reason = error
194
+ await self._set_state(STATE_STOPPED)
195
+
196
+ async def _handle_connection_error(self, error):
197
+ """Handle connection errors."""
198
+ self.failed_attempts += 1
199
+ if self.failed_attempts > MAX_FAILED_ATTEMPTS:
200
+ self._error_reason = ERROR_TOO_MANY_RETRIES
201
+ await self._set_state(STATE_STOPPED)
202
+ elif self.state != STATE_STOPPED:
203
+ retry_delay = min(2 ** (self.failed_attempts - 1) * 30, 300)
204
+ _LOGGER.error(
205
+ "Websocket connection failed, retrying in %ds: %s",
206
+ retry_delay,
207
+ error,
208
+ )
209
+ await self._set_state(STATE_DISCONNECTED)
210
+ await asyncio.sleep(retry_delay)
211
+
212
+ async def listen(self):
213
+ """Start the listening websocket."""
214
+ await self._ensure_session()
215
+ self.failed_attempts = 0
216
+ self._listener_loop = asyncio.get_running_loop()
217
+ try:
218
+ while self.state != STATE_STOPPED:
219
+ await self.running()
220
+ finally:
221
+ self._listener_loop = None
222
+
223
+ async def _ensure_session(self):
224
+ """Ensure aiohttp.ClientSession exists."""
225
+ if self.session is None:
226
+ self.session = aiohttp.ClientSession()
227
+ self._session_external = False
228
+
229
+ async def close(self):
230
+ """Close the listening websocket."""
231
+ await self._set_state(STATE_STOPPED)
232
+
233
+ if self._tasks:
234
+ for task in self._tasks:
235
+ task.cancel()
236
+ await asyncio.gather(*self._tasks, return_exceptions=True)
237
+ self._tasks.clear()
238
+
239
+ if self._client is not None:
240
+ await self._client.close()
241
+ 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
+
247
+ async def keepalive(self):
248
+ """Send ping requests to websocket."""
249
+ if self._ping and self._pong:
250
+ time_delta = self._pong - self._ping
251
+ if time_delta < datetime.timedelta(0):
252
+ # Negative time should indicate no pong reply so consider the
253
+ # websocket disconnected.
254
+ self._error_reason = ERROR_PING_TIMEOUT
255
+ await self._set_state(STATE_DISCONNECTED)
256
+
257
+ data = {"ping": 1}
258
+ _LOGGER.debug("Sending message: %s to websocket.", data)
259
+ try:
260
+ if self._client:
261
+ await self._client.send_json(data)
262
+ self._ping = datetime.datetime.now()
263
+ _LOGGER.debug("Ping message sent.")
264
+ else:
265
+ _LOGGER.warning("Websocket client not found.")
266
+ except TypeError as err:
267
+ _LOGGER.error("Attempt to send ping data failed: %s", err)
268
+ except ValueError as err:
269
+ _LOGGER.error("Error parsing data: %s", err)
270
+ except RuntimeError as err:
271
+ _LOGGER.debug("Websocket connection issue: %s", err)
272
+ await self._set_state(STATE_DISCONNECTED)
273
+ except Exception as err: # pylint: disable=broad-exception-caught
274
+ _LOGGER.debug("Problem sending ping request: %s", err)
275
+ await self._set_state(STATE_DISCONNECTED)
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: python_openevse_http
3
+ Version: 0.0.0
4
+ Summary: Python wrapper for OpenEVSE HTTP API
5
+ Home-page: https://github.com/firstof9/python-openevse-http
6
+ Download-URL: https://github.com/firstof9/python-openevse-http
7
+ Author: firstof9
8
+ Author-email: firstof9@gmail.com
9
+ License: Apache-2.0
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Natural Language :: English
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: License :: OSI Approved :: Apache Software License
17
+ Requires-Python: >=3.13
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: aiohttp
21
+ Dynamic: author
22
+ Dynamic: author-email
23
+ Dynamic: classifier
24
+ Dynamic: description
25
+ Dynamic: description-content-type
26
+ Dynamic: download-url
27
+ Dynamic: home-page
28
+ Dynamic: license
29
+ Dynamic: license-file
30
+ Dynamic: requires-dist
31
+ Dynamic: requires-python
32
+ Dynamic: summary
33
+
34
+ ![Codecov branch](https://img.shields.io/codecov/c/github/firstof9/python-openevse-http/main?style=flat-square)
35
+ ![GitHub commit activity (branch)](https://img.shields.io/github/commit-activity/m/firstof9/python-openevse-http?style=flat-square)
36
+ ![GitHub last commit](https://img.shields.io/github/last-commit/firstof9/python-openevse-http?style=flat-square)
37
+ ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/firstof9/python-openevse-http?style=flat-square)
38
+
39
+ # python-openevse-http
40
+
41
+ A Python library for communicating with [OpenEVSE](https://www.openevse.com/) chargers via the HTTP API on ESP8266 and ESP32-based WiFi modules.
42
+
43
+ ## Features
44
+
45
+ - **Asynchronous**: Built on `aiohttp` for non-blocking I/O.
46
+ - **WebSocket Support**: Real-time updates for charger status.
47
+ - **Firmware Support**: Compatible with ESP8266 (2.x) and ESP32 (4.x+) WiFi firmware.
48
+ - **Comprehensive API**:
49
+ - Query status and configuration.
50
+ - Manage manual overrides.
51
+ - Control charging claims and limits.
52
+ - Handle schedules.
53
+ - **Shaper Toggle**: Enable or disable the grid shaper feature (requires firmware 4.0.0+).
54
+
55
+ ## Installation
56
+
57
+ ```bash
58
+ pip install python_openevse_http
59
+ ```
60
+
61
+ ## Quick Start
62
+
63
+ ```python
64
+ import asyncio
65
+ from openevsehttp import OpenEVSE
66
+
67
+ async def main():
68
+ # Initialize the charger
69
+ charger = OpenEVSE("192.168.1.30")
70
+
71
+ # Update state
72
+ await charger.update()
73
+
74
+ print(f"Charger State: {charger.status}")
75
+ print(f"Current Charge: {charger.charge_current}A")
76
+
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()
87
+
88
+ if __name__ == "__main__":
89
+ asyncio.run(main())
90
+ ```
91
+
92
+ ## API Support Matrix
93
+
94
+ | Endpoint | Methods | Supported | Description |
95
+ | :--- | :--- | :---: | :--- |
96
+ | `/status` | GET, POST | ✅ | Real-time status, sensors, and **Vehicle SoC** pushing |
97
+ | `/config` | GET, POST | ✅ | System and WiFi configuration |
98
+ | `/override` | GET, POST, PATCH, DELETE | ✅ | Manual charging overrides & current limits |
99
+ | `/claims` | GET, POST, DELETE | ✅ | Client-based charging claims |
100
+ | `/schedule` | GET, POST | ✅ | Charging schedule management |
101
+ | `/limit` | GET, POST, DELETE | ✅ | Charge limits (Time, Energy, SoC) |
102
+ | `/shaper` | POST | ✅ | Grid shaper control (v4.0.0+) |
103
+ | `/restart` | POST | ✅ | Reboot WiFi gateway or EVSE module |
104
+ | `/divertmode` | POST | ✅ | Solar divert mode control |
105
+ | `/r` (RAPI) | POST | ✅ | Direct RAPI command interface |
106
+ | `/ws` | GET | ✅ | WebSocket real-time updates |
107
+ | `/time` | GET, POST | ❌ | RTC and NTP time settings |
108
+ | `/logs` | GET | ❌ | System and debug event logs |
109
+ | `/emeter` | DELETE | ❌ | Energy meter reset |
110
+ | `/wifi` | GET, POST | ❌ | Network scanning and AP configuration |
111
+ | `/tesla` | GET | ❌ | Tesla vehicle integration |
112
+ | `/certificates`| GET, POST, DELETE | ❌ | SSL/TLS certificate management |
113
+ | `/schedule/plan`| GET | ❌ | Schedule planning and optimization |
114
+ | `/update` | POST | ❌ | Firmware update interface |
115
+ | `/rfid/add` | POST | ❌ | RFID tag management |
116
+
117
+ ✅ = Fully Supported \| ⚠️ = Partial Support \| ❌ = Not yet implemented
118
+
119
+ ## License
120
+
121
+ This project is licensed under the Apache-2.0 License.
@@ -0,0 +1,16 @@
1
+ openevsehttp/__init__.py,sha256=I6a1mjOZHYiWb_qfCuDuFLOOncrkkB_7uwybtOIujfY,1165
2
+ openevsehttp/__main__.py,sha256=EHmSdT7GjAVvHQxvLBTjZXsj_V5SB6B2_kpgUAT7mPM,146
3
+ openevsehttp/client.py,sha256=JBAC1jJGdzOabVAqHv8x6EJDVjhaW4t7Iwqn4lDWhwE,17502
4
+ openevsehttp/commands.py,sha256=lANhgVhtJJlQxLwFlMrxk3DrmnY2bXK5h4z3o9o6ZEk,20617
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=QVSyn_5a7vI1b4TdnnToRdw6veVCfnp7a19VYit95hg,17107
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.0.0.dist-info/licenses/LICENSE,sha256=hSB6TOQ7rmwSGb6XzqRjDGMvmUj5_GlacqQin3tegtA,11341
13
+ python_openevse_http-0.0.0.dist-info/METADATA,sha256=iC4mhS9bDUxzmip1gDyiuDFohEkeJTWmfjL_FNTP5sA,4363
14
+ python_openevse_http-0.0.0.dist-info/WHEEL,sha256=SmOxYU7pzNKBqASvQJ7DjX3XGUF92lrGhMb3R6_iiqI,91
15
+ python_openevse_http-0.0.0.dist-info/top_level.txt,sha256=u8RUkoEIE33Cjn6gmqiEoVpZ0VZ59WJ3FXBwwOg0CPE,13
16
+ python_openevse_http-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (79.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+