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,511 @@
1
+ """Command methods for the OpenEVSE charger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from collections.abc import Mapping
8
+ from typing import Any
9
+
10
+ import aiohttp # type: ignore
11
+ from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError
12
+ from awesomeversion import AwesomeVersion
13
+ from awesomeversion.exceptions import AwesomeVersionCompareException
14
+
15
+ from .const import MAX_AMPS, MIN_AMPS, RAPI_ERRORS, SUCCESS_ANSWERS, divert_mode
16
+ from .exceptions import UnknownError, UnsupportedFeature
17
+ from .utils import get_awesome_version
18
+
19
+ _LOGGER = logging.getLogger(__name__)
20
+
21
+
22
+ class CommandsMixin:
23
+ """Mixin providing command methods for OpenEVSE."""
24
+
25
+ url: str
26
+ _status: dict
27
+ _config: dict
28
+ _session: Any
29
+
30
+ # These are defined in client.py
31
+ def _version_check(self, min_version: str, max_version: str = "") -> bool:
32
+ raise NotImplementedError
33
+
34
+ async def process_request(
35
+ self, url: str, method: str = "", data: Any = None, rapi: Any = None
36
+ ) -> Mapping[str, Any] | list[Any] | str:
37
+ raise NotImplementedError
38
+
39
+ async def send_command(self, command: str) -> tuple:
40
+ raise NotImplementedError
41
+
42
+ async def update(self) -> None:
43
+ raise NotImplementedError
44
+
45
+ def _normalize_response(self, response: Any) -> dict[str, Any] | list[Any]:
46
+ """Normalize response to a dict or list."""
47
+ raise NotImplementedError
48
+
49
+ async def get_schedule(self) -> Mapping[str, Any] | list[Any]:
50
+ """Return the current schedule."""
51
+ url = f"{self.url}schedule"
52
+
53
+ _LOGGER.debug("Getting current schedule from %s", url)
54
+ response = await self.process_request(url=url, method="post")
55
+ return self._normalize_response(response)
56
+
57
+ async def set_charge_mode(self, mode: str = "fast") -> None:
58
+ """Set the charge mode at startup setting."""
59
+ url = f"{self.url}config"
60
+
61
+ if mode not in ["fast", "eco"]:
62
+ _LOGGER.error("Invalid value for charge_mode: %s", mode)
63
+ raise ValueError
64
+
65
+ data = {"charge_mode": mode}
66
+
67
+ _LOGGER.debug("Setting charge mode to %s", mode)
68
+ response = await self.process_request(url=url, method="post", data=data)
69
+ response = self._normalize_response(response)
70
+ msg = response.get("msg") if isinstance(response, Mapping) else None
71
+ if msg not in SUCCESS_ANSWERS:
72
+ _LOGGER.error("Problem issuing command: %s", response)
73
+ raise UnknownError
74
+
75
+ async def divert_mode(self) -> Mapping[str, Any] | list[Any]:
76
+ """Set the divert mode to either Normal or Eco modes."""
77
+ if not self._config:
78
+ raise RuntimeError("Missing configuration: self._config is required")
79
+
80
+ if not self._version_check("2.9.1"):
81
+ _LOGGER.debug("Feature not supported for older firmware.")
82
+ raise UnsupportedFeature
83
+
84
+ if "divert_enabled" in self._config:
85
+ _LOGGER.debug("Divert Enabled: %s", self._config["divert_enabled"])
86
+ mode = not self._config["divert_enabled"]
87
+ else:
88
+ _LOGGER.debug("Unable to check divert status.")
89
+ raise UnsupportedFeature
90
+
91
+ url = f"{self.url}config"
92
+ data = {"divert_enabled": mode}
93
+
94
+ _LOGGER.debug("Toggling divert: %s", mode)
95
+ response = await self.process_request(url=url, method="post", data=data)
96
+ _LOGGER.debug("divert_mode response: %s", response)
97
+ normalized_response = self._normalize_response(response)
98
+ if (
99
+ isinstance(normalized_response, dict)
100
+ and normalized_response.get("msg") in SUCCESS_ANSWERS
101
+ ):
102
+ self._config["divert_enabled"] = mode
103
+ return normalized_response
104
+
105
+ async def get_override(self) -> Mapping[str, Any] | list[Any]:
106
+ """Get the manual override status."""
107
+ if not self._version_check("4.0.1"):
108
+ _LOGGER.debug("Feature not supported for older firmware.")
109
+ raise UnsupportedFeature
110
+ url = f"{self.url}override"
111
+
112
+ _LOGGER.debug("Getting data from %s", url)
113
+ response = await self.process_request(url=url, method="get")
114
+ return self._normalize_response(response)
115
+
116
+ async def set_override(
117
+ self,
118
+ state: str | None = None,
119
+ charge_current: int | None = None,
120
+ max_current: int | None = None,
121
+ energy_limit: int | None = None,
122
+ time_limit: int | None = None,
123
+ auto_release: bool | None = None,
124
+ ) -> Any:
125
+ """Set the manual override status."""
126
+ if not self._version_check("4.0.1"):
127
+ _LOGGER.debug("Feature not supported for older firmware.")
128
+ raise UnsupportedFeature
129
+ url = f"{self.url}override"
130
+
131
+ response = await self.get_override()
132
+ if not isinstance(response, Mapping) or (
133
+ len(response) == 1 and "msg" in response
134
+ ):
135
+ _LOGGER.error("Invalid override payload: %s", response)
136
+ raise ValueError("Invalid override state response")
137
+
138
+ if state not in ["active", "disabled", None]:
139
+ _LOGGER.error("Invalid override state: %s", state)
140
+ raise ValueError
141
+
142
+ data: dict[str, Any] = {}
143
+ if auto_release is not None:
144
+ data["auto_release"] = auto_release
145
+
146
+ if state is not None:
147
+ data["state"] = state
148
+ if charge_current is not None:
149
+ data["charge_current"] = charge_current
150
+ if max_current is not None:
151
+ data["max_current"] = max_current
152
+ if energy_limit is not None:
153
+ data["energy_limit"] = energy_limit
154
+ if time_limit is not None:
155
+ data["time_limit"] = time_limit
156
+
157
+ _LOGGER.debug("Override data: %s", data)
158
+ _LOGGER.debug("Setting override config on %s", url)
159
+ reply = await self.process_request(url=url, method="post", data=data)
160
+ return self._normalize_response(reply)
161
+
162
+ async def toggle_override(self) -> None:
163
+ """Toggle the manual override status."""
164
+ # 3.x: use RAPI commands $FE (enable) and $FS (sleep)
165
+ # 4.x: use HTTP API call
166
+ lower = "4.0.1"
167
+ if self._version_check(lower):
168
+ url = f"{self.url}override"
169
+
170
+ _LOGGER.debug("Toggling manual override %s", url)
171
+ response = await self.process_request(url=url, method="patch")
172
+ response = self._normalize_response(response)
173
+ _LOGGER.debug("Toggle response: %s", response)
174
+ if (
175
+ not isinstance(response, Mapping)
176
+ or response.get("msg") not in SUCCESS_ANSWERS
177
+ ):
178
+ _LOGGER.error("Problem toggling override: %s", response)
179
+ raise RuntimeError(f"Failed to toggle override: {response}")
180
+ else:
181
+ # Older firmware use RAPI commands
182
+ _LOGGER.debug("Toggling manual override via RAPI")
183
+ if "state" not in self._status:
184
+ await self.update()
185
+
186
+ if "state" not in self._status:
187
+ _LOGGER.error("Cannot toggle override: unknown charger state.")
188
+ raise RuntimeError("Cannot toggle override: unknown charger state.")
189
+
190
+ command = "$FE" if self._status.get("state") == 254 else "$FS"
191
+ response, msg = await self.send_command(command)
192
+ _LOGGER.debug("Toggle response: %s", msg)
193
+ if response in [False, "NK"] or (
194
+ isinstance(msg, str) and (msg.startswith("$NK") or msg in RAPI_ERRORS)
195
+ ):
196
+ _LOGGER.error("Problem toggling override via RAPI: %s", msg)
197
+ raise RuntimeError(f"Failed to toggle override via RAPI: {msg}")
198
+
199
+ async def clear_override(self) -> None:
200
+ """Clear the manual override status."""
201
+ if not self._version_check("4.0.1"):
202
+ _LOGGER.debug("Feature not supported for older firmware.")
203
+ raise UnsupportedFeature
204
+ url = f"{self.url}override"
205
+
206
+ _LOGGER.debug("Clearing manual override %s", url)
207
+ response = await self.process_request(url=url, method="delete")
208
+ response = self._normalize_response(response)
209
+ msg = response.get("msg") if isinstance(response, Mapping) else None
210
+ _LOGGER.debug("Clear override response: %s", msg)
211
+ if msg not in SUCCESS_ANSWERS:
212
+ _LOGGER.error("Problem clearing override: %s", response)
213
+ raise RuntimeError(f"Failed to clear override: {response}")
214
+
215
+ async def set_current(self, amps: int = 6) -> None:
216
+ """Set the soft current limit."""
217
+ # 3.x - 4.1.0: use RAPI commands $SC <amps>
218
+ # 4.1.2: use HTTP API call
219
+ if isinstance(amps, bool) or not isinstance(amps, int):
220
+ _LOGGER.error("Invalid type for current limit: %s (%s)", amps, type(amps))
221
+ raise ValueError(
222
+ f"Current limit must be an integer, got {type(amps).__name__}"
223
+ )
224
+
225
+ min_current = self._config.get("min_current_hard", MIN_AMPS)
226
+ max_current = self._config.get("max_current_hard", MAX_AMPS)
227
+ if amps < min_current or amps > max_current:
228
+ _LOGGER.error("Invalid value for current limit: %s", amps)
229
+ raise ValueError(
230
+ f"Current limit {amps} is out of range ({min_current}-{max_current})"
231
+ )
232
+
233
+ if self._version_check("4.1.2"):
234
+ _LOGGER.debug("Setting current limit to %s", amps)
235
+ response = await self.set_override(charge_current=amps)
236
+ _LOGGER.debug("Set current response: %s", response)
237
+ if (
238
+ not isinstance(response, Mapping)
239
+ or response.get("msg") not in SUCCESS_ANSWERS
240
+ ):
241
+ _LOGGER.error("Problem setting current limit: %s", response)
242
+ raise UnknownError
243
+
244
+ else:
245
+ # RAPI commands
246
+ _LOGGER.debug("Setting current via RAPI")
247
+ command = f"$SC {amps} N"
248
+ # Different parameters for older firmware
249
+ if self._version_check("2.9.1"):
250
+ command = f"$SC {amps} V"
251
+ response, msg = await self.send_command(command)
252
+ _LOGGER.debug("Set current response: %s", msg)
253
+ if response in [False, "NK"] or (
254
+ isinstance(msg, str) and (msg.startswith("$NK") or msg in RAPI_ERRORS)
255
+ ):
256
+ _LOGGER.error("Problem setting current via RAPI: %s", msg)
257
+ raise UnknownError
258
+
259
+ async def set_service_level(self, level: int | str = 2) -> None:
260
+ """Set the service level of the EVSE."""
261
+ if isinstance(level, bool) or (
262
+ not (isinstance(level, int) and 0 <= level <= 2) and level != "A"
263
+ ):
264
+ _LOGGER.error("Invalid service level: %s", level)
265
+ raise ValueError
266
+
267
+ url = f"{self.url}config"
268
+ data = {"service": level}
269
+
270
+ _LOGGER.debug("Set service level to: %s", level)
271
+ response = await self.process_request(url=url, method="post", data=data)
272
+ response = self._normalize_response(response)
273
+ _LOGGER.debug("service response: %s", response)
274
+ msg = response.get("msg") if isinstance(response, Mapping) else None
275
+ if msg not in SUCCESS_ANSWERS:
276
+ _LOGGER.error("Problem issuing command: %s", response)
277
+ raise UnknownError
278
+
279
+ # Restart OpenEVSE WiFi
280
+ async def restart_wifi(self) -> None:
281
+ """Restart OpenEVSE WiFi module."""
282
+ url = f"{self.url}restart"
283
+ data = {"device": "gateway"}
284
+
285
+ response = await self.process_request(url=url, method="post", data=data)
286
+ response = self._normalize_response(response)
287
+
288
+ msg = (
289
+ response.get("msg", "Unknown error")
290
+ if isinstance(response, Mapping)
291
+ else "Unknown error"
292
+ )
293
+ _LOGGER.debug("WiFi Restart response: %s", msg)
294
+
295
+ # Strict success check:
296
+ # 1. Must be a Mapping
297
+ # 2. Must have "result" == "OK" OR "success" is True
298
+ # 3. Must NOT have an "error" key
299
+ # 4. If "msg" is present, it must be "OK" or contain "ok" (case-insensitive)
300
+ success = (
301
+ isinstance(response, Mapping)
302
+ and (response.get("result") == "OK" or response.get("success") is True)
303
+ and not response.get("error")
304
+ )
305
+ if success and isinstance(response, Mapping) and "msg" in response:
306
+ msg_val = str(response["msg"]).lower()
307
+ if msg_val != "ok" and "ok" not in msg_val:
308
+ success = False
309
+
310
+ if not success:
311
+ _LOGGER.error("Problem restarting WiFi: %s", response)
312
+ raise RuntimeError(f"Failed to restart WiFi: {msg}")
313
+
314
+ # Restart EVSE module
315
+ async def restart_evse(self) -> None:
316
+ """Restart EVSE module."""
317
+ if self._version_check("5.0.0"):
318
+ _LOGGER.debug("Restarting EVSE module via HTTP")
319
+ url = f"{self.url}restart"
320
+ data = {"device": "evse"}
321
+ reply = await self.process_request(url=url, method="post", data=data)
322
+ reply = self._normalize_response(reply)
323
+ if not isinstance(reply, Mapping) or (
324
+ reply.get("result") not in (None, 0, "OK", "ok", True)
325
+ or reply.get("msg") in RAPI_ERRORS
326
+ or reply.get("msg") in ["NK", False]
327
+ or reply.get("error")
328
+ ):
329
+ _LOGGER.error("Problem restarting EVSE module via HTTP: %s", reply)
330
+ raise RuntimeError(f"Failed to restart EVSE module via HTTP: {reply}")
331
+
332
+ response = (
333
+ reply.get("msg", "Unknown error")
334
+ if isinstance(reply, Mapping)
335
+ else "Unknown error"
336
+ )
337
+
338
+ else:
339
+ _LOGGER.debug("Restarting EVSE module via RAPI")
340
+ command = "$FR"
341
+ reply, response = await self.send_command(command)
342
+ if reply in [False, "NK"] or (
343
+ isinstance(response, str)
344
+ and (response.startswith("$NK") or response in RAPI_ERRORS)
345
+ ):
346
+ _LOGGER.error("Problem restarting EVSE module via RAPI: %s", response)
347
+ raise RuntimeError(
348
+ f"Failed to restart EVSE module via RAPI: {response}"
349
+ )
350
+
351
+ _LOGGER.debug("EVSE Restart response: %s", response)
352
+
353
+ # Firmware version
354
+ async def firmware_check(self) -> dict | None:
355
+ """Return the latest firmware version."""
356
+ if "version" not in self._config:
357
+ # Throw warning if we can't find the version
358
+ _LOGGER.debug("Unable to find firmware version.")
359
+ return None
360
+ base_url = "https://api.github.com/repos/OpenEVSE/"
361
+ url = None
362
+ method = "get"
363
+
364
+ cutoff = AwesomeVersion("3.0.0")
365
+ _LOGGER.debug("Detected firmware: %s", self._config["version"])
366
+
367
+ current = get_awesome_version(self._config["version"])
368
+ _LOGGER.debug("Using version: %s", current)
369
+
370
+ try:
371
+ if current >= cutoff:
372
+ url = f"{base_url}ESP32_WiFi_V4.x/releases/latest"
373
+ else:
374
+ url = f"{base_url}ESP8266_WiFi_v2.x/releases/latest"
375
+ except AwesomeVersionCompareException:
376
+ _LOGGER.debug("Non-semver firmware version detected.")
377
+ return None
378
+
379
+ try:
380
+ if (session := self._session) is None:
381
+ async with aiohttp.ClientSession() as session:
382
+ return await self._firmware_check_with_session(session, url, method)
383
+ else:
384
+ return await self._firmware_check_with_session(session, url, method)
385
+
386
+ except (TimeoutError, ServerTimeoutError):
387
+ _LOGGER.error("%s: %s", "Timeout while updating", url)
388
+ except ContentTypeError as err:
389
+ _LOGGER.error("%s", err)
390
+ except aiohttp.ClientConnectorError as err:
391
+ _LOGGER.error("%s : %s", err, url)
392
+
393
+ return None
394
+
395
+ async def _firmware_check_with_session(
396
+ self, session: aiohttp.ClientSession, url: str, method: str
397
+ ) -> dict | None:
398
+ """Process a firmware check request with a given session."""
399
+ http_method = getattr(session, method)
400
+ _LOGGER.debug(
401
+ "Connecting to %s using method %s",
402
+ url,
403
+ method,
404
+ )
405
+ async with http_method(url) as resp:
406
+ if resp.status != 200:
407
+ return None
408
+ message = await resp.text()
409
+ try:
410
+ message = json.loads(message)
411
+ except json.JSONDecodeError:
412
+ _LOGGER.error("Failed to parse JSON response: %s", message)
413
+ return None
414
+
415
+ if not isinstance(message, dict):
416
+ return None
417
+ return {
418
+ "latest_version": message.get("tag_name"),
419
+ "release_notes": message.get("body"),
420
+ "release_url": message.get("html_url"),
421
+ }
422
+
423
+ async def set_led_brightness(self, level: int) -> None:
424
+ """Set LED brightness level."""
425
+ if isinstance(level, bool) or not isinstance(level, int):
426
+ _LOGGER.error(
427
+ "Invalid type for LED brightness: %s (%s)", level, type(level)
428
+ )
429
+ raise TypeError(
430
+ f"LED brightness must be an integer, got {type(level).__name__}"
431
+ )
432
+ if not 0 <= level <= 255:
433
+ _LOGGER.error("Invalid value for LED brightness: %s", level)
434
+ raise ValueError(f"LED brightness {level} is out of range (0-255)")
435
+
436
+ if not self._version_check("4.1.0"):
437
+ _LOGGER.debug("Feature not supported for older firmware.")
438
+ raise UnsupportedFeature
439
+
440
+ url = f"{self.url}config"
441
+ data: dict[str, Any] = {}
442
+
443
+ data["led_brightness"] = level
444
+ _LOGGER.debug("Setting LED brightness to %s", level)
445
+ response = await self.process_request(url=url, method="post", data=data)
446
+ response = self._normalize_response(response)
447
+ msg = response.get("msg") if isinstance(response, Mapping) else None
448
+ if msg not in SUCCESS_ANSWERS:
449
+ _LOGGER.error("Problem issuing command: %s", response)
450
+ raise UnknownError
451
+
452
+ async def set_divert_mode(self, mode: str = "fast") -> None:
453
+ """Set the divert mode."""
454
+ url = f"{self.url}divertmode"
455
+ if mode not in ["fast", "eco"]:
456
+ _LOGGER.error("Invalid value for divert mode: %s", mode)
457
+ raise ValueError
458
+ _LOGGER.debug("Setting divert mode to %s", mode)
459
+ # convert text to int
460
+ new_mode = divert_mode[mode]
461
+
462
+ data = f"divertmode={new_mode}"
463
+
464
+ response = await self.process_request(url=url, method="post", rapi=data)
465
+ success = False
466
+ if isinstance(response, str):
467
+ res_lower = response.lower()
468
+ if "divert" in res_lower and "changed" in res_lower:
469
+ success = True
470
+ elif isinstance(response, dict) and response.get("msg") in SUCCESS_ANSWERS:
471
+ success = True
472
+
473
+ if not success:
474
+ _LOGGER.error("Problem issuing command: %s", response)
475
+ raise UnknownError
476
+
477
+ self._status["divertmode"] = new_mode
478
+
479
+ async def set_shaper(self, enable: bool = True) -> None:
480
+ """Set shaper mode."""
481
+ if not self._version_check("4.0.0"):
482
+ _LOGGER.debug("Feature not supported for older firmware.")
483
+ raise UnsupportedFeature
484
+
485
+ url = f"{self.url}shaper"
486
+ mode = 1 if enable else 0
487
+ data = {"shaper": mode}
488
+
489
+ _LOGGER.debug("Setting shaper to %s", mode)
490
+ response = await self.process_request(url=url, method="post", rapi=data)
491
+ response = self._normalize_response(response)
492
+ msg = response.get("msg") if isinstance(response, Mapping) else None
493
+ if msg not in SUCCESS_ANSWERS and msg != "Current Shaper state changed":
494
+ _LOGGER.error("Problem issuing command: %s", response)
495
+ raise UnknownError
496
+
497
+ self._status["shaper"] = mode
498
+
499
+ async def toggle_shaper(self) -> None:
500
+ """Toggle shaper mode."""
501
+ shaper_active = self._status.get("shaper")
502
+ if shaper_active is None:
503
+ await self.update()
504
+ shaper_active = self._status.get("shaper")
505
+
506
+ if shaper_active is None:
507
+ _LOGGER.error("Cannot toggle shaper: unknown shaper state.")
508
+ raise RuntimeError("Cannot toggle shaper: unknown shaper state.")
509
+
510
+ new_state = not bool(shaper_active)
511
+ await self.set_shaper(new_state)
openevsehttp/const.py ADDED
@@ -0,0 +1,64 @@
1
+ """Constants for the OpenEVSE HTTP python library."""
2
+
3
+ USER_AGENT = "python-openevse-http"
4
+ MIN_AMPS = 6
5
+ MAX_AMPS = 48
6
+
7
+ ERROR_TIMEOUT = "Timeout while updating"
8
+ INFO_LOOP_RUNNING = "Event loop already running, not creating new one."
9
+ UPDATE_TRIGGERS = [
10
+ "config_version",
11
+ "claims_version",
12
+ "override_version",
13
+ "schedule_version",
14
+ "schedule_plan_version",
15
+ "limit_version",
16
+ ]
17
+
18
+ SOLAR = "solar"
19
+
20
+ GRID = "grid_ie"
21
+ BAT_LVL = "battery_level"
22
+ BAT_RANGE = "battery_range"
23
+ TTF = "time_to_full_charge"
24
+ VOLTAGE = "voltage"
25
+ SHAPER_LIVE = "shaper_live_pwr"
26
+ TYPE = "type"
27
+ VALUE = "value"
28
+ RELEASE = "release"
29
+ # https://github.com/OpenEVSE/openevse_esp32_firmware/blob/master/src/evse_man.h#L28
30
+ CLIENT = 20
31
+
32
+ states = {
33
+ 0: "unknown",
34
+ 1: "not connected",
35
+ 2: "connected",
36
+ 3: "charging",
37
+ 4: "vent required",
38
+ 5: "diode check failed",
39
+ 6: "gfci fault",
40
+ 7: "no ground",
41
+ 8: "stuck relay",
42
+ 9: "gfci self-test failure",
43
+ 10: "over temperature",
44
+ 254: "sleeping",
45
+ 255: "disabled",
46
+ }
47
+
48
+ divert_mode = {
49
+ "fast": 1,
50
+ "eco": 2,
51
+ }
52
+
53
+ RAPI_ERRORS = [
54
+ "RAPI_RESPONSE_QUEUE_FULL",
55
+ "RAPI_RESPONSE_BUFFER_OVERFLOW",
56
+ "RAPI_RESPONSE_TIMEOUT",
57
+ "RAPI_RESPONSE_INVALID_RESPONSE",
58
+ "RAPI_RESPONSE_CMD_TOO_LONG",
59
+ "RAPI_RESPONSE_NOT_FOUND",
60
+ "RAPI_RESPONSE_BLOCKED",
61
+ "RAPI_RESPONSE_INVALID_COMMAND",
62
+ ]
63
+
64
+ SUCCESS_ANSWERS = ["OK", "done", "no change", "Created", "Updated", "Deleted"]
@@ -0,0 +1,33 @@
1
+ """Exceptions."""
2
+
3
+
4
+ class AuthenticationError(Exception):
5
+ """Exception for authentication errors."""
6
+
7
+
8
+ class ParseJSONError(Exception):
9
+ """Exception for JSON parsing errors."""
10
+
11
+
12
+ class UnknownError(Exception):
13
+ """Exception for Unknown errors."""
14
+
15
+
16
+ class MissingMethod(Exception):
17
+ """Exception for missing method variable."""
18
+
19
+
20
+ class AlreadyListening(Exception):
21
+ """Exception for already listening websocket."""
22
+
23
+
24
+ class MissingSerial(Exception):
25
+ """Exception for missing serial number."""
26
+
27
+
28
+ class UnsupportedFeature(Exception):
29
+ """Exception for firmware that is too old."""
30
+
31
+
32
+ class InvalidType(Exception):
33
+ """Exception for invalid types."""