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,157 @@
1
+ """Manager methods (limits, claims) 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 CLIENT, RELEASE, TYPE, VALUE
10
+ from .exceptions import InvalidType, UnsupportedFeature
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ class ManagersMixin:
16
+ """Mixin providing limit and claim management 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
+ # Limit endpoint
34
+ async def set_limit(
35
+ self, limit_type: str, value: int, release: bool | None = None
36
+ ) -> Any:
37
+ """Set charge limit."""
38
+ if not self._version_check("5.0.0"):
39
+ _LOGGER.debug("Feature not supported for older firmware.")
40
+ raise UnsupportedFeature
41
+
42
+ valid_types = ["time", "energy", "soc", "range"]
43
+
44
+ if limit_type not in valid_types:
45
+ raise InvalidType
46
+
47
+ url = f"{self.url}limit"
48
+ fetched_data = await self.get_limit()
49
+
50
+ data: dict[str, Any] = {}
51
+ if isinstance(fetched_data, Mapping):
52
+ for key in (TYPE, VALUE, RELEASE):
53
+ if key in fetched_data:
54
+ data[key] = fetched_data[key]
55
+
56
+ data[TYPE] = limit_type
57
+ data[VALUE] = value
58
+ if release is not None:
59
+ data[RELEASE] = release
60
+
61
+ _LOGGER.debug("Limit data: %s", data)
62
+ _LOGGER.debug("Setting limit config on %s", url)
63
+ response = await self.process_request(url=url, method="post", data=data)
64
+ return self._normalize_response(response)
65
+
66
+ async def clear_limit(self) -> Any:
67
+ """Clear charge limit."""
68
+ if not self._version_check("5.0.0"):
69
+ _LOGGER.debug("Feature not supported for older firmware.")
70
+ raise UnsupportedFeature
71
+
72
+ url = f"{self.url}limit"
73
+
74
+ _LOGGER.debug("Clearing limit config on %s", url)
75
+ response = await self.process_request(url=url, method="delete")
76
+ return self._normalize_response(response)
77
+
78
+ async def get_limit(self) -> Any:
79
+ """Get charge limit."""
80
+ if not self._version_check("5.0.0"):
81
+ _LOGGER.debug("Feature not supported for older firmware.")
82
+ raise UnsupportedFeature
83
+
84
+ url = f"{self.url}limit"
85
+
86
+ _LOGGER.debug("Getting limit config on %s", url)
87
+ response = await self.process_request(url=url, method="get")
88
+ return self._normalize_response(response)
89
+
90
+ async def make_claim(
91
+ self,
92
+ state: str | None = None,
93
+ charge_current: int | None = None,
94
+ max_current: int | None = None,
95
+ auto_release: bool = True,
96
+ client: int = CLIENT,
97
+ ) -> Any:
98
+ """Make a claim."""
99
+ if not self._version_check("4.1.0"):
100
+ _LOGGER.debug("Feature not supported for older firmware.")
101
+ raise UnsupportedFeature
102
+
103
+ if state not in ["active", "disabled", None]:
104
+ _LOGGER.error("Invalid claim state: %s", state)
105
+ raise ValueError(
106
+ f"Invalid claim state: {state}. Allowed: ['active', 'disabled', None]"
107
+ )
108
+
109
+ url = f"{self.url}claims/{client}"
110
+
111
+ data: dict[str, Any] = {}
112
+
113
+ data["auto_release"] = auto_release
114
+
115
+ if state is not None:
116
+ data["state"] = state
117
+ if charge_current is not None:
118
+ data["charge_current"] = charge_current
119
+ if max_current is not None:
120
+ data["max_current"] = max_current
121
+
122
+ _LOGGER.debug("Claim data: %s", data)
123
+ _LOGGER.debug("Setting up claim on %s", url)
124
+ response = await self.process_request(url=url, method="post", data=data)
125
+ return self._normalize_response(response)
126
+
127
+ async def release_claim(self, client: int = CLIENT) -> Any:
128
+ """Delete a claim."""
129
+ if not self._version_check("4.1.0"):
130
+ _LOGGER.debug("Feature not supported for older firmware.")
131
+ raise UnsupportedFeature
132
+
133
+ url = f"{self.url}claims/{client}"
134
+
135
+ _LOGGER.debug("Releasing claim on %s", url)
136
+ response = await self.process_request(url=url, method="delete")
137
+ return self._normalize_response(response)
138
+
139
+ async def list_claims(self, target: bool | None = None) -> Any:
140
+ """List all claims.
141
+
142
+ :param target: If True, returns the target (active) claim.
143
+ If False or None (default), returns a list of all claims.
144
+ """
145
+ if not self._version_check("4.1.0"):
146
+ _LOGGER.debug("Feature not supported for older firmware.")
147
+ raise UnsupportedFeature
148
+
149
+ target_check = ""
150
+ if target is True:
151
+ target_check = "/target"
152
+
153
+ url = f"{self.url}claims{target_check}"
154
+
155
+ _LOGGER.debug("Getting claims on %s", url)
156
+ response = await self.process_request(url=url, method="get")
157
+ return self._normalize_response(response)
@@ -0,0 +1,527 @@
1
+ """Property accessors for the OpenEVSE charger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Mapping
7
+ from datetime import datetime, timedelta, timezone
8
+ from typing import Any
9
+
10
+ from .const import MAX_AMPS, MIN_AMPS, states
11
+ from .exceptions import UnsupportedFeature
12
+ from .utils import normalize_version
13
+
14
+ _LOGGER = logging.getLogger(__name__)
15
+
16
+
17
+ class PropertiesMixin:
18
+ """Mixin providing all @property accessors for OpenEVSE."""
19
+
20
+ _status: dict
21
+ _config: dict
22
+
23
+ # These are used by properties but defined in client.py
24
+ def _version_check(self, min_version: str, max_version: str = "") -> bool:
25
+ raise NotImplementedError
26
+
27
+ async def list_claims(self, target: bool | None = None) -> Any:
28
+ raise NotImplementedError
29
+
30
+ async def get_override(self) -> Mapping[str, Any] | list[Any]:
31
+ raise NotImplementedError
32
+
33
+ @property
34
+ def led_brightness(self) -> int | None:
35
+ """Return charger led_brightness."""
36
+ if not self._version_check("4.1.0"):
37
+ _LOGGER.debug("Feature not supported for older firmware.")
38
+ raise UnsupportedFeature
39
+ return self._config.get("led_brightness")
40
+
41
+ @property
42
+ def hostname(self) -> str | None:
43
+ """Return charger hostname."""
44
+ return self._config.get("hostname")
45
+
46
+ @property
47
+ def wifi_ssid(self) -> str | None:
48
+ """Return charger connected SSID."""
49
+ return self._config.get("ssid")
50
+
51
+ @property
52
+ def ammeter_offset(self) -> int | None:
53
+ """Return ammeter's current offset."""
54
+ return self._config.get("offset")
55
+
56
+ @property
57
+ def ammeter_scale_factor(self) -> int | None:
58
+ """Return ammeter's current scale factor."""
59
+ return self._config.get("scale")
60
+
61
+ @property
62
+ def temp_check_enabled(self) -> bool:
63
+ """Return True if enabled, False if disabled."""
64
+ return bool(self._config.get("tempt", False))
65
+
66
+ @property
67
+ def diode_check_enabled(self) -> bool:
68
+ """Return True if enabled, False if disabled."""
69
+ return bool(self._config.get("diodet", False))
70
+
71
+ @property
72
+ def vent_required_enabled(self) -> bool:
73
+ """Return True if enabled, False if disabled."""
74
+ return bool(self._config.get("ventt", False))
75
+
76
+ @property
77
+ def ground_check_enabled(self) -> bool:
78
+ """Return True if enabled, False if disabled."""
79
+ return bool(self._config.get("groundt", False))
80
+
81
+ @property
82
+ def stuck_relay_check_enabled(self) -> bool:
83
+ """Return True if enabled, False if disabled."""
84
+ return bool(self._config.get("relayt", False))
85
+
86
+ @property
87
+ def service_level(self) -> int | str | None:
88
+ """Return the service level (1, 2, or 'A')."""
89
+ return self._config.get("service")
90
+
91
+ @property
92
+ def openevse_firmware(self) -> str | None:
93
+ """Return the firmware version."""
94
+ return self._config.get("firmware")
95
+
96
+ @property
97
+ def max_current_soft(self) -> int | None:
98
+ """Return the max current soft."""
99
+ if "max_current_soft" in self._config:
100
+ return self._config.get("max_current_soft")
101
+ return self._status.get("pilot")
102
+
103
+ async def get_charge_current(self) -> int | None:
104
+ """Get the charge current."""
105
+ try:
106
+ claims_data = await self.list_claims(target=True)
107
+ claims = claims_data if isinstance(claims_data, list) else [claims_data]
108
+ for claim in claims:
109
+ if isinstance(claim, dict):
110
+ properties = claim.get("properties")
111
+ if isinstance(properties, dict) and "charge_current" in properties:
112
+ try:
113
+ charge_current = int(properties["charge_current"])
114
+ max_hard = int(self._config.get("max_current_hard", 48))
115
+ return min(charge_current, max_hard)
116
+ except (TypeError, ValueError):
117
+ pass
118
+ except (UnsupportedFeature, IndexError, KeyError):
119
+ pass
120
+
121
+ if "max_current_soft" in self._config:
122
+ return self._config.get("max_current_soft")
123
+ return self._status.get("pilot")
124
+
125
+ @property
126
+ def max_current(self) -> int | None:
127
+ """Return the max current."""
128
+ return self._status.get("max_current", None)
129
+
130
+ @property
131
+ def wifi_firmware(self) -> str | None:
132
+ """Return the ESP firmware version."""
133
+ value = self._config.get("version")
134
+ if value is not None:
135
+ value = normalize_version(value)
136
+ return value
137
+
138
+ @property
139
+ def ip_address(self) -> str | None:
140
+ """Return the ip address."""
141
+ return self._status.get("ipaddress")
142
+
143
+ @property
144
+ def charging_voltage(self) -> int | None:
145
+ """Return the charging voltage."""
146
+ return self._status.get("voltage")
147
+
148
+ @property
149
+ def mode(self) -> str | None:
150
+ """Return the mode."""
151
+ return self._status.get("mode")
152
+
153
+ @property
154
+ def using_ethernet(self) -> bool:
155
+ """Return True if enabled, False if disabled."""
156
+ return bool(self._status.get("eth_connected", False))
157
+
158
+ @property
159
+ def stuck_relay_trip_count(self) -> int | None:
160
+ """Return the stuck relay count."""
161
+ return self._status.get("stuckcount")
162
+
163
+ @property
164
+ def no_gnd_trip_count(self) -> int | None:
165
+ """Return the no ground count."""
166
+ return self._status.get("nogndcount")
167
+
168
+ @property
169
+ def gfi_trip_count(self) -> int | None:
170
+ """Return the GFCI count."""
171
+ return self._status.get("gfcicount")
172
+
173
+ @property
174
+ def status(self) -> str:
175
+ """Return charger's state."""
176
+ # Check if "status" is already a non-null string in _status (some versions)
177
+ val = self._status.get("status")
178
+ if val is not None:
179
+ return str(val)
180
+
181
+ # Fall back to state mapping
182
+ return self.state
183
+
184
+ @property
185
+ def state(self) -> str:
186
+ """Return charger's state."""
187
+ try:
188
+ code = int(self._status.get("state", 0))
189
+ except (ValueError, TypeError):
190
+ code = 0
191
+ return states.get(code, "unknown")
192
+
193
+ @property
194
+ def state_raw(self) -> int | None:
195
+ """Return charger's state int form."""
196
+ return self._status.get("state")
197
+
198
+ @property
199
+ def charge_time_elapsed(self) -> int | None:
200
+ """Return elapsed charging time."""
201
+ return self._status.get("elapsed")
202
+
203
+ @property
204
+ def wifi_signal(self) -> int | None:
205
+ """Return charger's wifi signal."""
206
+ return self._status.get("srssi")
207
+
208
+ @property
209
+ def charging_current(self) -> float | None:
210
+ """Return the charge current.
211
+
212
+ 0 if is not currently charging.
213
+ """
214
+ return self._status.get("amp")
215
+
216
+ @property
217
+ def current_capacity(self) -> int | None:
218
+ """Return the current capacity."""
219
+ return self._status.get("pilot")
220
+
221
+ @property
222
+ def usage_total(self) -> float | None:
223
+ """Return the total energy usage in Wh."""
224
+ if "total_energy" in self._status:
225
+ return self._status.get("total_energy")
226
+ return self._status.get("watthour")
227
+
228
+ @property
229
+ def ambient_temperature(self) -> float | None:
230
+ """Return the temperature of the ambient sensor, in degrees Celsius."""
231
+ for key in ["temp", "temp1"]:
232
+ temp = self._status.get(key)
233
+ if temp is not None:
234
+ try:
235
+ return float(temp) / 10
236
+ except (ValueError, TypeError):
237
+ continue
238
+ return None
239
+
240
+ @property
241
+ def rtc_temperature(self) -> float | None:
242
+ """Return the temperature of the real time clock sensor."""
243
+ temp = self._status.get("temp2")
244
+ try:
245
+ if temp is None or isinstance(temp, bool):
246
+ return None
247
+ return float(temp) / 10
248
+ except (ValueError, TypeError):
249
+ return None
250
+
251
+ @property
252
+ def ir_temperature(self) -> float | None:
253
+ """Return the temperature of the IR remote sensor.
254
+
255
+ In degrees Celsius.
256
+ """
257
+ temp = self._status.get("temp3")
258
+ try:
259
+ if temp is None or isinstance(temp, bool):
260
+ return None
261
+ return float(temp) / 10
262
+ except (ValueError, TypeError):
263
+ return None
264
+
265
+ @property
266
+ def esp_temperature(self) -> float | None:
267
+ """Return the temperature of the ESP sensor, in degrees Celsius."""
268
+ temp = self._status.get("temp4")
269
+ try:
270
+ if temp is None or isinstance(temp, bool):
271
+ return None
272
+ return float(temp) / 10
273
+ except (ValueError, TypeError):
274
+ return None
275
+
276
+ @property
277
+ def time(self) -> datetime | None:
278
+ """Get the RTC time."""
279
+ value = self._status.get("time")
280
+ if value:
281
+ try:
282
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
283
+ except (ValueError, AttributeError):
284
+ return None
285
+ return None
286
+
287
+ @property
288
+ def usage_session(self) -> float | None:
289
+ """Get the energy usage for the current charging session.
290
+
291
+ Return the energy usage in Wh.
292
+ """
293
+ if "session_energy" in self._status:
294
+ return self._status.get("session_energy")
295
+ wattsec = self._status.get("wattsec")
296
+ if wattsec is not None:
297
+ try:
298
+ return float(round(float(wattsec) / 3600, 2))
299
+ except (ValueError, TypeError):
300
+ return None
301
+ return None
302
+
303
+ @property
304
+ def total_day(self) -> float | None:
305
+ """Get the total day energy usage."""
306
+ return self._status.get("total_day", None)
307
+
308
+ @property
309
+ def total_week(self) -> float | None:
310
+ """Get the total week energy usage."""
311
+ return self._status.get("total_week", None)
312
+
313
+ @property
314
+ def total_month(self) -> float | None:
315
+ """Get the total month energy usage."""
316
+ return self._status.get("total_month", None)
317
+
318
+ @property
319
+ def total_year(self) -> float | None:
320
+ """Get the total year energy usage."""
321
+ return self._status.get("total_year", None)
322
+
323
+ @property
324
+ def has_limit(self) -> bool | None:
325
+ """Return if a limit has been set."""
326
+ return self._status.get("has_limit", self._status.get("limit", None))
327
+
328
+ @property
329
+ def protocol_version(self) -> str | None:
330
+ """Return the protocol version."""
331
+ protocol = self._config.get("protocol")
332
+ if protocol == "-":
333
+ return None
334
+ return protocol
335
+
336
+ @property
337
+ def vehicle(self) -> bool:
338
+ """Return if a vehicle is connected to the EVSE."""
339
+ return self._status.get("vehicle", False)
340
+
341
+ @property
342
+ def ota_update(self) -> bool:
343
+ """Return if an OTA update is active."""
344
+ return self._status.get("ota_update", False)
345
+
346
+ @property
347
+ def manual_override(self) -> bool:
348
+ """Return if Manual Override is set."""
349
+ return self._status.get("manual_override", False)
350
+
351
+ @property
352
+ def divertmode(self) -> str:
353
+ """Return the divert mode."""
354
+ mode = self._status.get("divertmode", 1)
355
+ if mode == 1:
356
+ return "fast"
357
+ return "eco"
358
+
359
+ @property
360
+ def charge_mode(self) -> str | None:
361
+ """Return the charge mode."""
362
+ return self._config.get("charge_mode")
363
+
364
+ @property
365
+ def available_current(self) -> float | None:
366
+ """Return the computed available current for divert."""
367
+ return self._status.get("available_current")
368
+
369
+ @property
370
+ def smoothed_available_current(self) -> float | None:
371
+ """Return the computed smoothed available current for divert."""
372
+ return self._status.get("smoothed_available_current")
373
+
374
+ @property
375
+ def charge_rate(self) -> float | None:
376
+ """Return the divert charge rate."""
377
+ return self._status.get("charge_rate")
378
+
379
+ @property
380
+ def divert_active(self) -> bool:
381
+ """Return if divert is active."""
382
+ return bool(self._config.get("divert_enabled", False))
383
+
384
+ @property
385
+ def wifi_serial(self) -> str | None:
386
+ """Return wifi serial."""
387
+ return self._config.get("wifi_serial", None)
388
+
389
+ @property
390
+ def charging_power(self) -> float | None:
391
+ """Return the charge power.
392
+
393
+ Calculate Watts base on V*I
394
+ """
395
+ if self._status is not None and all(
396
+ key in self._status for key in ["voltage", "amp"]
397
+ ):
398
+ voltage = self._status["voltage"]
399
+ amp = self._status["amp"]
400
+ if isinstance(voltage, int | float) and isinstance(amp, int | float):
401
+ return round(voltage * amp, 2)
402
+ return None
403
+
404
+ # Shaper values
405
+ @property
406
+ def shaper_active(self) -> bool | None:
407
+ """Return if shaper is active."""
408
+ return self._status.get("shaper", None)
409
+
410
+ @property
411
+ def shaper_live_power(self) -> int | None:
412
+ """Return shaper live power reading."""
413
+ return self._status.get("shaper_live_pwr", None)
414
+
415
+ @property
416
+ def shaper_available_current(self) -> float | None:
417
+ """Return shaper available current."""
418
+ shaper_cur = self._status.get("shaper_cur")
419
+ if shaper_cur == 255:
420
+ return self._status.get("pilot")
421
+ return shaper_cur
422
+
423
+ @property
424
+ def shaper_max_power(self) -> int | None:
425
+ """Return shaper live power reading."""
426
+ return self._status.get("shaper_max_pwr", None)
427
+
428
+ @property
429
+ def shaper_updated(self) -> bool:
430
+ """Return shaper updated boolean."""
431
+ return bool(self._status.get("shaper_updated", False))
432
+
433
+ # Vehicle values
434
+ @property
435
+ def vehicle_soc(self) -> int | None:
436
+ """Return battery level."""
437
+ return self._status.get("vehicle_soc", self._status.get("battery_level", None))
438
+
439
+ @property
440
+ def vehicle_range(self) -> int | None:
441
+ """Return battery range."""
442
+ return self._status.get(
443
+ "vehicle_range", self._status.get("battery_range", None)
444
+ )
445
+
446
+ @property
447
+ def vehicle_eta(self) -> datetime | None:
448
+ """Return time to full charge."""
449
+ value = self._status.get(
450
+ "time_to_full_charge", self._status.get("vehicle_eta", None)
451
+ )
452
+ if value is None or isinstance(value, bool):
453
+ return None
454
+ try:
455
+ seconds = float(value)
456
+ except (TypeError, ValueError):
457
+ return None
458
+ return datetime.now(timezone.utc) + timedelta(seconds=seconds)
459
+
460
+ # There is currently no min/max amps JSON data
461
+ # available via HTTP API methods
462
+ @property
463
+ def min_amps(self) -> int:
464
+ """Return the minimum amps."""
465
+ return self._config.get("min_current_hard", MIN_AMPS)
466
+
467
+ @property
468
+ def max_amps(self) -> int:
469
+ """Return the maximum amps."""
470
+ return self._config.get("max_current_hard", MAX_AMPS)
471
+
472
+ @property
473
+ def mqtt_connected(self) -> bool:
474
+ """Return the status of the mqtt connection."""
475
+ return bool(self._status.get("mqtt_connected", False))
476
+
477
+ @property
478
+ def emoncms_connected(self) -> bool | None:
479
+ """Return the status of the emoncms connection."""
480
+ return self._status.get("emoncms_connected", None)
481
+
482
+ @property
483
+ def ocpp_connected(self) -> bool | None:
484
+ """Return the status of the ocpp connection."""
485
+ return self._status.get("ocpp_connected", None)
486
+
487
+ @property
488
+ def uptime(self) -> int | None:
489
+ """Return the unit uptime."""
490
+ return self._status.get("uptime", None)
491
+
492
+ @property
493
+ def freeram(self) -> int | None:
494
+ """Return the unit freeram."""
495
+ return self._status.get("freeram", None)
496
+
497
+ # Safety counts
498
+ @property
499
+ def checks_count(self) -> dict:
500
+ """Return the safety checks counts."""
501
+ attributes = ("gfcicount", "nogndcount", "stuckcount")
502
+ counts = {}
503
+ if self._status is not None and set(attributes).issubset(self._status.keys()):
504
+ counts["gfcicount"] = self._status["gfcicount"]
505
+ counts["nogndcount"] = self._status["nogndcount"]
506
+ counts["stuckcount"] = self._status["stuckcount"]
507
+ return counts
508
+
509
+ async def get_override_state(self) -> str | None:
510
+ """Get the unit override state."""
511
+ try:
512
+ override = await self.get_override()
513
+ except UnsupportedFeature:
514
+ _LOGGER.debug("Override state unavailable on older firmware.")
515
+ return None
516
+ if isinstance(override, dict):
517
+ state = override.get("state", "auto")
518
+ return "auto" if state is None else state
519
+ return "auto"
520
+
521
+ @property
522
+ def current_power(self) -> int:
523
+ """Return the current power (live) in watts."""
524
+ if not self._version_check("4.2.2"):
525
+ _LOGGER.debug("Feature not supported for older firmware.")
526
+ raise UnsupportedFeature
527
+ return self._status.get("power", 0)