eheimdigital 1.0.1__py2.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.
- eheimdigital/__init__.py +3 -0
- eheimdigital/classic_led_ctrl.py +150 -0
- eheimdigital/classic_vario.py +93 -0
- eheimdigital/device.py +58 -0
- eheimdigital/heater.py +101 -0
- eheimdigital/hub.py +162 -0
- eheimdigital/py.typed +0 -0
- eheimdigital/types.py +284 -0
- eheimdigital-1.0.1.dist-info/METADATA +11 -0
- eheimdigital-1.0.1.dist-info/RECORD +12 -0
- eheimdigital-1.0.1.dist-info/WHEEL +5 -0
- eheimdigital-1.0.1.dist-info/licenses/LICENSE +21 -0
eheimdigital/__init__.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""The EHEIM classicLEDcontrol light controller."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from logging import getLogger
|
|
7
|
+
from typing import TYPE_CHECKING, Any, override
|
|
8
|
+
|
|
9
|
+
from eheimdigital.device import EheimDigitalDevice
|
|
10
|
+
from eheimdigital.types import (
|
|
11
|
+
CCVPacket,
|
|
12
|
+
ClockPacket,
|
|
13
|
+
CloudPacket,
|
|
14
|
+
LightMode,
|
|
15
|
+
MoonPacket,
|
|
16
|
+
MsgTitle,
|
|
17
|
+
UsrDtaPacket,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from eheimdigital.hub import EheimDigitalHub
|
|
22
|
+
|
|
23
|
+
_LOGGER = getLogger(__package__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EheimDigitalClassicLEDControl(EheimDigitalDevice):
|
|
27
|
+
"""Represent a EHEIM classicLEDcontrol light controller."""
|
|
28
|
+
|
|
29
|
+
ccv: CCVPacket | None = None
|
|
30
|
+
clock: ClockPacket | None = None
|
|
31
|
+
cloud: CloudPacket | None = None
|
|
32
|
+
moon: MoonPacket | None = None
|
|
33
|
+
tankconfig: list[list[str]]
|
|
34
|
+
power: list[list[int]]
|
|
35
|
+
|
|
36
|
+
def __init__(self, hub: EheimDigitalHub, usrdta: UsrDtaPacket) -> None:
|
|
37
|
+
"""Initialize a classicLEDcontrol light controller."""
|
|
38
|
+
super().__init__(hub, usrdta)
|
|
39
|
+
self.tankconfig = json.loads(usrdta["tankconfig"])
|
|
40
|
+
self.power = json.loads(usrdta["power"])
|
|
41
|
+
|
|
42
|
+
@override
|
|
43
|
+
async def parse_message(self, msg: dict[str, Any]) -> None:
|
|
44
|
+
"""Parse a message."""
|
|
45
|
+
match msg["title"]:
|
|
46
|
+
case MsgTitle.CCV:
|
|
47
|
+
self.ccv = CCVPacket(**msg)
|
|
48
|
+
case MsgTitle.CLOUD:
|
|
49
|
+
self.cloud = CloudPacket(**msg)
|
|
50
|
+
case MsgTitle.MOON:
|
|
51
|
+
self.moon = MoonPacket(**msg)
|
|
52
|
+
case MsgTitle.CLOCK:
|
|
53
|
+
self.clock = ClockPacket(**msg)
|
|
54
|
+
case _:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
@override
|
|
58
|
+
async def update(self) -> None:
|
|
59
|
+
"""Get the new light state."""
|
|
60
|
+
await self.hub.send_packet({
|
|
61
|
+
"title": "REQ_CCV",
|
|
62
|
+
"to": self.mac_address,
|
|
63
|
+
"from": "USER",
|
|
64
|
+
})
|
|
65
|
+
await self.hub.send_packet({
|
|
66
|
+
"title": "GET_CLOCK",
|
|
67
|
+
"to": self.mac_address,
|
|
68
|
+
"from": "USER",
|
|
69
|
+
})
|
|
70
|
+
if "moon" not in self.__dict__:
|
|
71
|
+
await self.hub.send_packet({
|
|
72
|
+
"title": "GET_MOON",
|
|
73
|
+
"to": self.mac_address,
|
|
74
|
+
"from": "USER",
|
|
75
|
+
})
|
|
76
|
+
if "cloud" not in self.__dict__:
|
|
77
|
+
await self.hub.send_packet({
|
|
78
|
+
"title": "GET_CLOUD",
|
|
79
|
+
"to": self.mac_address,
|
|
80
|
+
"from": "USER",
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def light_level(self) -> tuple[int | None, int | None]:
|
|
85
|
+
"""Return the current light level of the channels."""
|
|
86
|
+
if self.ccv is None:
|
|
87
|
+
return (None, None)
|
|
88
|
+
return (
|
|
89
|
+
self.ccv["currentValues"][0] if len(self.tankconfig[0]) > 0 else None,
|
|
90
|
+
self.ccv["currentValues"][1] if len(self.tankconfig[1]) > 0 else None,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def power_consumption(self) -> tuple[float | None, float | None]:
|
|
95
|
+
"""Return the power consumption of the channels."""
|
|
96
|
+
if self.ccv is None:
|
|
97
|
+
return (None, None)
|
|
98
|
+
return (
|
|
99
|
+
sum(self.power[0]) * self.ccv["currentValues"][0]
|
|
100
|
+
if len(self.tankconfig[0]) > 0
|
|
101
|
+
else None,
|
|
102
|
+
sum(self.power[1]) * self.ccv["currentValues"][1]
|
|
103
|
+
if len(self.tankconfig[1]) > 0
|
|
104
|
+
else None,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def light_mode(self) -> LightMode | None:
|
|
109
|
+
"""Return the current light operation mode."""
|
|
110
|
+
if self.clock is None or "mode" not in self.clock:
|
|
111
|
+
return None
|
|
112
|
+
return LightMode(self.clock["mode"])
|
|
113
|
+
|
|
114
|
+
async def set_light_mode(self, mode: LightMode) -> None:
|
|
115
|
+
"""Set the light operation mode."""
|
|
116
|
+
await self.hub.send_packet({
|
|
117
|
+
"title": str(mode),
|
|
118
|
+
"to": self.mac_address,
|
|
119
|
+
"from": "USER",
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
async def turn_on(self, value: int, channel: int) -> None:
|
|
123
|
+
"""Set a new brightness value for a channel."""
|
|
124
|
+
if self.light_mode == LightMode.DAYCL_MODE:
|
|
125
|
+
await self.set_light_mode(LightMode.MAN_MODE)
|
|
126
|
+
if self.ccv is None:
|
|
127
|
+
return
|
|
128
|
+
currentvalues = self.ccv["currentValues"]
|
|
129
|
+
currentvalues[channel] = value
|
|
130
|
+
await self.hub.send_packet({
|
|
131
|
+
"title": "CCV-SL",
|
|
132
|
+
"currentValues": currentvalues,
|
|
133
|
+
"to": self.mac_address,
|
|
134
|
+
"from": "USER",
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
async def turn_off(self, channel: int) -> None:
|
|
138
|
+
"""Turn off a channel."""
|
|
139
|
+
if self.light_mode == LightMode.DAYCL_MODE:
|
|
140
|
+
await self.set_light_mode(LightMode.MAN_MODE)
|
|
141
|
+
if self.ccv is None:
|
|
142
|
+
return
|
|
143
|
+
currentvalues = self.ccv["currentValues"]
|
|
144
|
+
currentvalues[channel] = 0
|
|
145
|
+
await self.hub.send_packet({
|
|
146
|
+
"title": "CCV-SL",
|
|
147
|
+
"currentValues": currentvalues,
|
|
148
|
+
"to": self.mac_address,
|
|
149
|
+
"from": "USER",
|
|
150
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""The Eheim Digital classicVARIO filter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from logging import getLogger
|
|
6
|
+
from typing import TYPE_CHECKING, Any, override
|
|
7
|
+
|
|
8
|
+
from .device import EheimDigitalDevice
|
|
9
|
+
from .types import (
|
|
10
|
+
ClassicVarioDataPacket,
|
|
11
|
+
FilterMode,
|
|
12
|
+
MsgTitle,
|
|
13
|
+
UsrDtaPacket,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from eheimdigital.hub import EheimDigitalHub
|
|
18
|
+
|
|
19
|
+
_LOGGER = getLogger(__package__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EheimDigitalClassicVario(EheimDigitalDevice):
|
|
23
|
+
"""Represent a Eheim Digital classicVARIO filter."""
|
|
24
|
+
|
|
25
|
+
classic_vario_data: ClassicVarioDataPacket | None = None
|
|
26
|
+
|
|
27
|
+
def __init__(self, hub: EheimDigitalHub, usrdta: UsrDtaPacket) -> None:
|
|
28
|
+
"""Initialize a classicVARIO filter."""
|
|
29
|
+
super().__init__(hub, usrdta)
|
|
30
|
+
|
|
31
|
+
@override
|
|
32
|
+
async def parse_message(self, msg: dict[str, Any]) -> None:
|
|
33
|
+
"""Parse a message."""
|
|
34
|
+
if msg["title"] == MsgTitle.CLASSIC_VARIO_DATA:
|
|
35
|
+
self.classic_vario_data = ClassicVarioDataPacket(**msg)
|
|
36
|
+
|
|
37
|
+
@override
|
|
38
|
+
async def update(self) -> None:
|
|
39
|
+
"""Get the new filter state."""
|
|
40
|
+
await self.hub.send_packet({
|
|
41
|
+
"title": MsgTitle.GET_CLASSIC_VARIO_DATA,
|
|
42
|
+
"to": self.mac_address,
|
|
43
|
+
"from": "USER",
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
async def set_classic_vario_param(self, data: dict[str, Any]) -> None:
|
|
47
|
+
"""Send a SET_CLASSIC_VARIO_PARAM packet, containing new values from data."""
|
|
48
|
+
if self.classic_vario_data is None:
|
|
49
|
+
_LOGGER.error(
|
|
50
|
+
"set_classic_vario_param: No CLASSIC_VARIO_DATA packet received yet."
|
|
51
|
+
)
|
|
52
|
+
return
|
|
53
|
+
await self.hub.send_packet({
|
|
54
|
+
"title": "SET_CLASSIC_VARIO_PARAM",
|
|
55
|
+
"to": self.classic_vario_data["from"],
|
|
56
|
+
"filterActive": self.classic_vario_data["filterActive"],
|
|
57
|
+
"rel_manual_motor_speed": self.classic_vario_data["rel_manual_motor_speed"],
|
|
58
|
+
"rel_motor_speed_day": self.classic_vario_data["rel_motor_speed_day"],
|
|
59
|
+
"rel_motor_speed_night": self.classic_vario_data["rel_motor_speed_night"],
|
|
60
|
+
"startTime_day": self.classic_vario_data["startTime_day"],
|
|
61
|
+
"startTime_night": self.classic_vario_data["startTime_night"],
|
|
62
|
+
"pulse_motorSpeed_High": self.classic_vario_data["pulse_motorSpeed_High"],
|
|
63
|
+
"pulse_motorSpeed_Low": self.classic_vario_data["pulse_motorSpeed_Low"],
|
|
64
|
+
"pulse_Time_High": self.classic_vario_data["pulse_Time_High"],
|
|
65
|
+
"pulse_Time_Low": self.classic_vario_data["pulse_Time_Low"],
|
|
66
|
+
"pumpMode": self.classic_vario_data["pumpMode"],
|
|
67
|
+
"from": "USER",
|
|
68
|
+
**data,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def current_speed(self) -> int:
|
|
73
|
+
"""Return the current filter pump speed."""
|
|
74
|
+
return self.classic_vario_data["rel_speed"]
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def service_hours(self) -> int:
|
|
78
|
+
"""Return the amount of hours until the next service is needed."""
|
|
79
|
+
return self.classic_vario_data["serviceHour"]
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def filter_mode(self) -> FilterMode:
|
|
83
|
+
"""Return the current filter mode."""
|
|
84
|
+
return FilterMode(self.classic_vario_data["pumpMode"])
|
|
85
|
+
|
|
86
|
+
async def set_filter_mode(self, value: FilterMode) -> None:
|
|
87
|
+
"""Set the filter mode."""
|
|
88
|
+
await self.set_classic_vario_param({"pumpMode": value.value})
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def is_active(self) -> bool:
|
|
92
|
+
"""Return whether the filter is active."""
|
|
93
|
+
return bool(self.classic_vario_data["filterActive"])
|
eheimdigital/device.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The Eheim Digital device."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import abstractmethod
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from .types import EheimDeviceType
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .hub import EheimDigitalHub
|
|
13
|
+
from .types import UsrDtaPacket
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EheimDigitalDevice:
|
|
17
|
+
"""Represent a Eheim Digital device."""
|
|
18
|
+
|
|
19
|
+
hub: EheimDigitalHub
|
|
20
|
+
usrdta: UsrDtaPacket
|
|
21
|
+
|
|
22
|
+
def __init__(self, hub: EheimDigitalHub, usrdta: UsrDtaPacket) -> None:
|
|
23
|
+
"""Initialize a device."""
|
|
24
|
+
self.hub = hub
|
|
25
|
+
self.usrdta = usrdta
|
|
26
|
+
|
|
27
|
+
@cached_property
|
|
28
|
+
def name(self) -> str:
|
|
29
|
+
"""Device name."""
|
|
30
|
+
return self.usrdta["name"]
|
|
31
|
+
|
|
32
|
+
@cached_property
|
|
33
|
+
def mac_address(self) -> str:
|
|
34
|
+
"""Device MAC address."""
|
|
35
|
+
return self.usrdta["from"]
|
|
36
|
+
|
|
37
|
+
@cached_property
|
|
38
|
+
def sw_version(self) -> str:
|
|
39
|
+
"""Device software version."""
|
|
40
|
+
return f"{self.usrdta["revision"][0] // 1000}.{(self.usrdta["revision"][0] % 1000) // 100}.{self.usrdta["revision"][0] % 100}_{self.usrdta["revision"][1] // 1000}.{(self.usrdta["revision"][1] % 1000) // 100}.{self.usrdta["revision"][1] % 100}"
|
|
41
|
+
|
|
42
|
+
@cached_property
|
|
43
|
+
def device_type(self) -> EheimDeviceType:
|
|
44
|
+
"""Device type."""
|
|
45
|
+
return EheimDeviceType(self.usrdta["version"])
|
|
46
|
+
|
|
47
|
+
@cached_property
|
|
48
|
+
def aquarium_name(self) -> str:
|
|
49
|
+
"""Aquarium name."""
|
|
50
|
+
return self.usrdta["aqName"]
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
async def parse_message(self, msg: dict[str, Any]) -> None:
|
|
54
|
+
"""Parse a message."""
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
async def update(self) -> None:
|
|
58
|
+
"""Update a device state."""
|
eheimdigital/heater.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""The Eheim Digital Heater."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, override
|
|
6
|
+
|
|
7
|
+
from .device import EheimDigitalDevice
|
|
8
|
+
from .types import (
|
|
9
|
+
HeaterDataPacket,
|
|
10
|
+
HeaterMode,
|
|
11
|
+
HeaterUnit,
|
|
12
|
+
MsgTitle,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .hub import EheimDigitalHub
|
|
17
|
+
from .types import UsrDtaPacket
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EheimDigitalHeater(EheimDigitalDevice):
|
|
21
|
+
"""Represent a Eheim Digital Heater."""
|
|
22
|
+
|
|
23
|
+
heater_data: HeaterDataPacket | None = None
|
|
24
|
+
|
|
25
|
+
def __init__(self, hub: EheimDigitalHub, usrdta: UsrDtaPacket) -> None:
|
|
26
|
+
"""Initialize a heater."""
|
|
27
|
+
super().__init__(hub, usrdta)
|
|
28
|
+
|
|
29
|
+
async def parse_message(self, msg: dict) -> None:
|
|
30
|
+
"""Parse a message."""
|
|
31
|
+
if msg["title"] == MsgTitle.HEATER_DATA:
|
|
32
|
+
self.heater_data = HeaterDataPacket(**msg)
|
|
33
|
+
|
|
34
|
+
@override
|
|
35
|
+
async def update(self) -> None:
|
|
36
|
+
"""Get the new heater state."""
|
|
37
|
+
await self.hub.send_packet({
|
|
38
|
+
"title": MsgTitle.GET_EHEATER_DATA,
|
|
39
|
+
"to": self.mac_address,
|
|
40
|
+
"from": "USER",
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
@override
|
|
44
|
+
async def set_eheater_param(self, data: dict[str, Any]) -> None:
|
|
45
|
+
"""Send a SET_EHEATER_PARAM packet, containing new values from data."""
|
|
46
|
+
await self.hub.send_packet({
|
|
47
|
+
"title": "SET_EHEATER_PARAM",
|
|
48
|
+
"to": self.heater_data["from"],
|
|
49
|
+
"mUnit": self.heater_data["mUnit"],
|
|
50
|
+
"sollTemp": self.heater_data["sollTemp"],
|
|
51
|
+
"active": self.heater_data["active"],
|
|
52
|
+
"hystLow": self.heater_data["hystLow"],
|
|
53
|
+
"hystHigh": self.heater_data["hystHigh"],
|
|
54
|
+
"offset": self.heater_data["offset"],
|
|
55
|
+
"mode": self.heater_data["mode"],
|
|
56
|
+
"sync": self.heater_data["sync"],
|
|
57
|
+
"partnerName": self.heater_data["partnerName"],
|
|
58
|
+
"dayStartT": self.heater_data["dayStartT"],
|
|
59
|
+
"nightStartT": self.heater_data["nightStartT"],
|
|
60
|
+
"nReduce": self.heater_data["nReduce"],
|
|
61
|
+
"from": "USER",
|
|
62
|
+
**data,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def temperature_unit(self) -> HeaterUnit:
|
|
67
|
+
"""Return the temperature unit."""
|
|
68
|
+
return HeaterUnit(self.heater_data["mUnit"])
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def current_temperature(self) -> float:
|
|
72
|
+
"""Return the current temperature."""
|
|
73
|
+
return self.heater_data["isTemp"] / 10
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def target_temperature(self) -> float:
|
|
77
|
+
"""Return the target temperature."""
|
|
78
|
+
return self.heater_data["sollTemp"] / 10
|
|
79
|
+
|
|
80
|
+
async def set_target_temperature(self, value: float) -> None:
|
|
81
|
+
"""Set a new target temperature."""
|
|
82
|
+
await self.set_eheater_param({"sollTemp": int(value * 10)})
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def temperature_offset(self) -> float:
|
|
86
|
+
"""Return the temperature offset."""
|
|
87
|
+
return self.heater_data["offset"] / 10
|
|
88
|
+
|
|
89
|
+
async def set_temperature_offset(self, value: float) -> None:
|
|
90
|
+
"""Set a temperature offset."""
|
|
91
|
+
await self.set_eheater_param({"offset": int(value * 10)})
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def operation_mode(self) -> HeaterMode:
|
|
95
|
+
"""Return the heater operation mode."""
|
|
96
|
+
return HeaterMode(self.heater_data["mode"])
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def is_heating(self) -> bool:
|
|
100
|
+
"""Return whether the heater is heating."""
|
|
101
|
+
return bool(self.heater_data["isHeating"])
|
eheimdigital/hub.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""The Eheim Digital hub."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from logging import getLogger
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
from yarl import URL
|
|
11
|
+
|
|
12
|
+
from .classic_led_ctrl import EheimDigitalClassicLEDControl
|
|
13
|
+
from .classic_vario import EheimDigitalClassicVario
|
|
14
|
+
from .heater import EheimDigitalHeater
|
|
15
|
+
from .types import EheimDeviceType, MeshNetworkPacket, MsgTitle, UsrDtaPacket
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Awaitable
|
|
19
|
+
|
|
20
|
+
from .device import EheimDigitalDevice
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_LOGGER = getLogger(__package__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EheimDigitalHub:
|
|
27
|
+
"""Represent a Eheim Digital hub."""
|
|
28
|
+
|
|
29
|
+
devices: dict[str, EheimDigitalDevice]
|
|
30
|
+
loop: asyncio.AbstractEventLoop
|
|
31
|
+
main: EheimDigitalDevice | None
|
|
32
|
+
receive_callback: Callable[[], Awaitable[None]] | None
|
|
33
|
+
receive_task: asyncio.Task[None] | None = None
|
|
34
|
+
url: URL
|
|
35
|
+
ws: aiohttp.ClientWebSocketResponse | None = None
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
host: str = "eheimdigital.local",
|
|
41
|
+
session: aiohttp.ClientSession | None = None,
|
|
42
|
+
loop: asyncio.AbstractEventLoop | None = None,
|
|
43
|
+
receive_callback: Callable[[], Awaitable[None]] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Initialize a hub."""
|
|
46
|
+
self.url = URL.build(scheme="http", host=host, path="/ws")
|
|
47
|
+
self.session = session or aiohttp.ClientSession()
|
|
48
|
+
self.devices = {}
|
|
49
|
+
self.loop = loop or asyncio.get_event_loop()
|
|
50
|
+
self.receive_callback = receive_callback
|
|
51
|
+
self.main = None
|
|
52
|
+
|
|
53
|
+
async def connect(self) -> None: # pragma: no cover
|
|
54
|
+
"""Connect to the hub."""
|
|
55
|
+
self.ws = await self.session.ws_connect(self.url)
|
|
56
|
+
self.receive_task = self.loop.create_task(self.receive_messages())
|
|
57
|
+
|
|
58
|
+
async def close(self) -> None: # pragma: no cover
|
|
59
|
+
"""Close the connection."""
|
|
60
|
+
if self.receive_task is not None:
|
|
61
|
+
_ = self.receive_task.cancel()
|
|
62
|
+
if self.ws is not None and not self.ws.closed:
|
|
63
|
+
_ = await self.ws.close()
|
|
64
|
+
|
|
65
|
+
def add_device(self, usrdta: UsrDtaPacket) -> None:
|
|
66
|
+
"""Add a device to the device list."""
|
|
67
|
+
match EheimDeviceType(usrdta["version"]):
|
|
68
|
+
case EheimDeviceType.VERSION_EHEIM_EXT_HEATER:
|
|
69
|
+
self.devices[usrdta["from"]] = EheimDigitalHeater(self, usrdta)
|
|
70
|
+
case EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO:
|
|
71
|
+
self.devices[usrdta["from"]] = EheimDigitalClassicVario(self, usrdta)
|
|
72
|
+
case EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E:
|
|
73
|
+
self.devices[usrdta["from"]] = EheimDigitalClassicLEDControl(
|
|
74
|
+
self, usrdta
|
|
75
|
+
)
|
|
76
|
+
case _:
|
|
77
|
+
_LOGGER.debug(
|
|
78
|
+
"Found device %s with unsupported device type %s",
|
|
79
|
+
usrdta["from"],
|
|
80
|
+
EheimDeviceType(usrdta["version"]),
|
|
81
|
+
)
|
|
82
|
+
if self.main is None and usrdta["from"] in self.devices:
|
|
83
|
+
self.main = self.devices[usrdta["from"]]
|
|
84
|
+
|
|
85
|
+
async def request_usrdta(self, mac_address: str) -> None:
|
|
86
|
+
"""Request the USRDTA of a device."""
|
|
87
|
+
await self.send_packet({
|
|
88
|
+
"title": MsgTitle.GET_USRDTA,
|
|
89
|
+
"to": mac_address,
|
|
90
|
+
"from": "USER",
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
async def send_packet(self, packet: dict[str, Any]) -> None:
|
|
94
|
+
"""Send a packet to the hub."""
|
|
95
|
+
if self.ws is not None:
|
|
96
|
+
await self.ws.send_json(packet)
|
|
97
|
+
|
|
98
|
+
async def parse_mesh_network(self, msg: MeshNetworkPacket) -> None:
|
|
99
|
+
"""Parse a MESH_NETWORK packet."""
|
|
100
|
+
for client in msg["clientList"]:
|
|
101
|
+
if client not in self.devices:
|
|
102
|
+
await self.request_usrdta(client)
|
|
103
|
+
|
|
104
|
+
async def parse_usrdta(self, msg: UsrDtaPacket) -> None:
|
|
105
|
+
"""Parse a USRDTA packet."""
|
|
106
|
+
if msg["from"] not in self.devices:
|
|
107
|
+
self.add_device(msg)
|
|
108
|
+
|
|
109
|
+
async def parse_message(self, msg: dict[str, Any]) -> None:
|
|
110
|
+
"""Parse a received message."""
|
|
111
|
+
if "from" not in msg:
|
|
112
|
+
_LOGGER.debug("Received message without 'from' property: %s", msg)
|
|
113
|
+
return
|
|
114
|
+
if "USER" in msg["from"]:
|
|
115
|
+
_LOGGER.debug("Received message from other user: %s", msg)
|
|
116
|
+
return
|
|
117
|
+
if "title" not in msg:
|
|
118
|
+
_LOGGER.debug("Received message without 'title' property: %s", msg)
|
|
119
|
+
return
|
|
120
|
+
match msg["title"]:
|
|
121
|
+
case MsgTitle.MESH_NETWORK:
|
|
122
|
+
_LOGGER.debug("Received mesh network packet: %s", msg)
|
|
123
|
+
await self.parse_mesh_network(MeshNetworkPacket(**msg))
|
|
124
|
+
case MsgTitle.USRDTA:
|
|
125
|
+
_LOGGER.debug("Received usrdta packet: %s", msg)
|
|
126
|
+
await self.parse_usrdta(UsrDtaPacket(**msg))
|
|
127
|
+
if self.receive_callback:
|
|
128
|
+
await self.receive_callback()
|
|
129
|
+
case _:
|
|
130
|
+
_LOGGER.debug(
|
|
131
|
+
"Received packet %s for device %s: %s",
|
|
132
|
+
msg["title"],
|
|
133
|
+
msg["from"],
|
|
134
|
+
msg,
|
|
135
|
+
)
|
|
136
|
+
if "from" in msg and msg["from"] in self.devices:
|
|
137
|
+
await self.devices[msg["from"]].parse_message(msg)
|
|
138
|
+
if self.receive_callback:
|
|
139
|
+
await self.receive_callback()
|
|
140
|
+
|
|
141
|
+
async def receive_messages(self) -> None:
|
|
142
|
+
"""Receive messages from the hub."""
|
|
143
|
+
if self.ws is None:
|
|
144
|
+
_LOGGER.error("receive_task called without an established connection!")
|
|
145
|
+
return
|
|
146
|
+
while True:
|
|
147
|
+
async for msg in self.ws:
|
|
148
|
+
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
149
|
+
msgdata: list[dict[str, Any]] | dict[str, Any] = msg.json()
|
|
150
|
+
if isinstance(msgdata, list):
|
|
151
|
+
for part in msgdata:
|
|
152
|
+
await self.parse_message(part)
|
|
153
|
+
else:
|
|
154
|
+
await self.parse_message(msgdata)
|
|
155
|
+
|
|
156
|
+
async def update(self) -> None:
|
|
157
|
+
"""Update the device states."""
|
|
158
|
+
if self.ws is None:
|
|
159
|
+
await self.connect()
|
|
160
|
+
await self.request_usrdta("ALL")
|
|
161
|
+
for device in self.devices.values():
|
|
162
|
+
await device.update()
|
eheimdigital/py.typed
ADDED
|
File without changes
|
eheimdigital/types.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Types for Eheim Digital.""" # noqa: A005
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import IntEnum, StrEnum
|
|
6
|
+
from typing import Literal, NotRequired, TypedDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HeaterUnit(IntEnum):
|
|
10
|
+
"""Heater temperature unit."""
|
|
11
|
+
|
|
12
|
+
CELSIUS = 0
|
|
13
|
+
FAHRENHEIT = 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HeaterMode(IntEnum):
|
|
17
|
+
"""Heater operation mode."""
|
|
18
|
+
|
|
19
|
+
MANUAL = 0
|
|
20
|
+
BIO = 1
|
|
21
|
+
SMART = 2
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FilterMode(IntEnum):
|
|
25
|
+
"""Filter operation mode."""
|
|
26
|
+
|
|
27
|
+
MANUAL = 16
|
|
28
|
+
PULSE = 8
|
|
29
|
+
BIO = 4
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LightMode(StrEnum):
|
|
33
|
+
"""Light operation mode."""
|
|
34
|
+
|
|
35
|
+
DAYCL_MODE = "DAYCL_MODE"
|
|
36
|
+
MAN_MODE = "MAN_MODE"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MsgTitle(StrEnum):
|
|
40
|
+
"""Represent a message title."""
|
|
41
|
+
|
|
42
|
+
USRDTA = "USRDTA"
|
|
43
|
+
CLOCK = "CLOCK"
|
|
44
|
+
NET_ST = "NET_ST"
|
|
45
|
+
NET_AP = "NET_AP"
|
|
46
|
+
MESH_NETWORK = "MESH_NETWORK"
|
|
47
|
+
CLASSIC_VARIO_DATA = "CLASSIC_VARIO_DATA"
|
|
48
|
+
HEATER_DATA = "HEATER_DATA"
|
|
49
|
+
SET_EHEATER_PARAM = "SET_EHEATER_PARAM"
|
|
50
|
+
GET_USRDTA = "GET_USRDTA"
|
|
51
|
+
GET_EHEATER_DATA = "GET_EHEATER_DATA"
|
|
52
|
+
GET_CLASSIC_VARIO_DATA = "GET_CLASSIC_VARIO_DATA"
|
|
53
|
+
CCV = "CCV"
|
|
54
|
+
MOON = "MOON"
|
|
55
|
+
CLOUD = "CLOUD"
|
|
56
|
+
ACCLIMATE = "ACCLIMATE"
|
|
57
|
+
REQ_KEEP_ALIVE = "REQ_KEEP_ALIVE"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class EheimDeviceType(IntEnum):
|
|
61
|
+
"""Represent a device type."""
|
|
62
|
+
|
|
63
|
+
VERSION_UNDEFINED = 0
|
|
64
|
+
VERSION_HC = 1
|
|
65
|
+
VERSION_HC_PLUS = 2
|
|
66
|
+
VERSION_EHEIM_LIGHT = 3
|
|
67
|
+
VERSION_EHEIM_EXT_FILTER = 4
|
|
68
|
+
VERSION_EHEIM_EXT_HEATER = 5
|
|
69
|
+
VERSION_EHEIM_FEEDER = 6
|
|
70
|
+
VERSION_EHEIM_CHILLER = 7
|
|
71
|
+
VERSION_EHEIM_LIGHT_AQUAKIDS = 8
|
|
72
|
+
VERSION_EHEIM_PH_CONTROL = 9
|
|
73
|
+
VERSION_EHEIM_STREAM_CONTROL = 10
|
|
74
|
+
VERSION_EHEIM_REEFLEX = 11
|
|
75
|
+
VERSION_EHEIM_80_FILTER_WITH_HEAT = 12
|
|
76
|
+
VERSION_EHEIM_80_FILTER_WITHOUT_HEAT = 13
|
|
77
|
+
VERSION_EHEIM_DOSING_PUMP = 14
|
|
78
|
+
VERSION_EHEIM_LED_CTRL_PLUS_E = 15
|
|
79
|
+
VERSION_EHEIM_RGB_CTRL_PLUS_E = 16
|
|
80
|
+
VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E = 17
|
|
81
|
+
VERSION_EHEIM_CLASSIC_VARIO = 18
|
|
82
|
+
VERSION_EHEIM_CONDUCTION_METER = 19
|
|
83
|
+
VERSION_EHEIM_COMPACT_ON = 20
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def model_name(self) -> str | None:
|
|
87
|
+
"""Return the model name."""
|
|
88
|
+
match self:
|
|
89
|
+
case EheimDeviceType.VERSION_EHEIM_EXT_HEATER:
|
|
90
|
+
return "thermocontrol+e"
|
|
91
|
+
case EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO:
|
|
92
|
+
return "classicVARIO+e"
|
|
93
|
+
case EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E:
|
|
94
|
+
return "classicLEDcontrol+e"
|
|
95
|
+
case _:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
MeshNetworkPacket = TypedDict(
|
|
100
|
+
"MeshNetworkPacket",
|
|
101
|
+
{"title": str, "to": str, "clientList": list[str], "from": NotRequired[str]},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
UsrDtaPacket = TypedDict(
|
|
106
|
+
"UsrDtaPacket",
|
|
107
|
+
{
|
|
108
|
+
"aqName": str,
|
|
109
|
+
"build": NotRequired[list[str]],
|
|
110
|
+
"demoUse": int,
|
|
111
|
+
"dst": int,
|
|
112
|
+
"emailAddr": str,
|
|
113
|
+
"firmwareAvailable": int,
|
|
114
|
+
"firstStart": int,
|
|
115
|
+
"from": str,
|
|
116
|
+
"fstTime": NotRequired[int],
|
|
117
|
+
"groupID": int,
|
|
118
|
+
"host": str,
|
|
119
|
+
"language": str,
|
|
120
|
+
"latestAvailableRevision": list[int],
|
|
121
|
+
"liveTime": int,
|
|
122
|
+
"meshing": int,
|
|
123
|
+
"mode": NotRequired[str],
|
|
124
|
+
"name": str,
|
|
125
|
+
"netmode": str,
|
|
126
|
+
"power": str,
|
|
127
|
+
"remote": int,
|
|
128
|
+
"revision": list[int],
|
|
129
|
+
"softChange": NotRequired[int],
|
|
130
|
+
"sstTime": NotRequired[int],
|
|
131
|
+
"stMail": NotRequired[int],
|
|
132
|
+
"stMailMode": NotRequired[int],
|
|
133
|
+
"sysLED": int,
|
|
134
|
+
"tankconfig": str,
|
|
135
|
+
"tID": int,
|
|
136
|
+
"timezone": int,
|
|
137
|
+
"title": str,
|
|
138
|
+
"to": str,
|
|
139
|
+
"unit": int,
|
|
140
|
+
"usrName": str,
|
|
141
|
+
"version": int,
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
HeaterDataPacket = TypedDict(
|
|
146
|
+
"HeaterDataPacket",
|
|
147
|
+
{
|
|
148
|
+
"title": str,
|
|
149
|
+
"from": str,
|
|
150
|
+
"mUnit": int,
|
|
151
|
+
"sollTemp": int,
|
|
152
|
+
"isTemp": int,
|
|
153
|
+
"hystLow": int,
|
|
154
|
+
"hystHigh": int,
|
|
155
|
+
"offset": int,
|
|
156
|
+
"active": int,
|
|
157
|
+
"isHeating": int,
|
|
158
|
+
"mode": int,
|
|
159
|
+
"sync": str,
|
|
160
|
+
"partnerName": str,
|
|
161
|
+
"dayStartT": int,
|
|
162
|
+
"nightStartT": int,
|
|
163
|
+
"nReduce": int,
|
|
164
|
+
"alertState": int,
|
|
165
|
+
"to": str,
|
|
166
|
+
},
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
SetEheaterParamPacket = TypedDict(
|
|
170
|
+
"SetEheaterParamPacket",
|
|
171
|
+
{
|
|
172
|
+
"title": Literal[MsgTitle.SET_EHEATER_PARAM],
|
|
173
|
+
"to": str,
|
|
174
|
+
"mUnit": int,
|
|
175
|
+
"sollTemp": int,
|
|
176
|
+
"active": int,
|
|
177
|
+
"hystLow": int,
|
|
178
|
+
"hystHigh": int,
|
|
179
|
+
"offset": int,
|
|
180
|
+
"mode": int,
|
|
181
|
+
"sync": str,
|
|
182
|
+
"partnerName": str,
|
|
183
|
+
"dayStartT": int,
|
|
184
|
+
"nightStartT": int,
|
|
185
|
+
"nReduce": int,
|
|
186
|
+
"from": Literal["USER"],
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
ClassicVarioDataPacket = TypedDict(
|
|
192
|
+
"ClassicVarioDataPacket",
|
|
193
|
+
{
|
|
194
|
+
"title": Literal[MsgTitle.CLASSIC_VARIO_DATA],
|
|
195
|
+
"from": str,
|
|
196
|
+
"rel_speed": int,
|
|
197
|
+
"pumpMode": int,
|
|
198
|
+
"filterActive": int,
|
|
199
|
+
"turnOffTime": int,
|
|
200
|
+
"serviceHour": int,
|
|
201
|
+
"rel_manual_motor_speed": int,
|
|
202
|
+
"rel_motor_speed_day": int,
|
|
203
|
+
"rel_motor_speed_night": int,
|
|
204
|
+
"startTime_day": int,
|
|
205
|
+
"startTime_night": int,
|
|
206
|
+
"pulse_motorSpeed_High": int,
|
|
207
|
+
"pulse_motorSpeed_Low": int,
|
|
208
|
+
"pulse_Time_High": int,
|
|
209
|
+
"pulse_Time_Low": int,
|
|
210
|
+
"turnTimeFeeding": int,
|
|
211
|
+
"errorCode": int,
|
|
212
|
+
"version": int,
|
|
213
|
+
},
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
CCVPacket = TypedDict(
|
|
217
|
+
"CCVPacket",
|
|
218
|
+
{
|
|
219
|
+
"title": Literal[MsgTitle.CCV],
|
|
220
|
+
"from": str,
|
|
221
|
+
"currentValues": list[int],
|
|
222
|
+
"to": str,
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
MoonPacket = TypedDict(
|
|
227
|
+
"MoonPacket",
|
|
228
|
+
{
|
|
229
|
+
"title": Literal[MsgTitle.MOON],
|
|
230
|
+
"from": str,
|
|
231
|
+
"maxmoonlight": int,
|
|
232
|
+
"minmoonlight": int,
|
|
233
|
+
"moonlightActive": int,
|
|
234
|
+
"moonlightCycle": int,
|
|
235
|
+
"to": str,
|
|
236
|
+
},
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
CloudPacket = TypedDict(
|
|
240
|
+
"CloudPacket",
|
|
241
|
+
{
|
|
242
|
+
"title": Literal[MsgTitle.CLOUD],
|
|
243
|
+
"from": str,
|
|
244
|
+
"probability": int,
|
|
245
|
+
"maxAmount": int,
|
|
246
|
+
"minIntensity": int,
|
|
247
|
+
"maxIntensity": int,
|
|
248
|
+
"minDuration": int,
|
|
249
|
+
"maxDuration": int,
|
|
250
|
+
"cloudActive": int,
|
|
251
|
+
"mode": int,
|
|
252
|
+
"to": str,
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
AcclimatePacket = TypedDict(
|
|
257
|
+
"AcclimatePacket",
|
|
258
|
+
{
|
|
259
|
+
"title": Literal[MsgTitle.ACCLIMATE],
|
|
260
|
+
"from": str,
|
|
261
|
+
"duration": int,
|
|
262
|
+
"intensityReduction": int,
|
|
263
|
+
"currentAcclDay": int,
|
|
264
|
+
"acclActive": int,
|
|
265
|
+
"pause": int,
|
|
266
|
+
"to": str,
|
|
267
|
+
},
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
ClockPacket = TypedDict(
|
|
271
|
+
"ClockPacket",
|
|
272
|
+
{
|
|
273
|
+
"title": Literal[MsgTitle.CLOCK],
|
|
274
|
+
"from": str,
|
|
275
|
+
"year": int,
|
|
276
|
+
"month": int,
|
|
277
|
+
"day": int,
|
|
278
|
+
"hour": int,
|
|
279
|
+
"min": int,
|
|
280
|
+
"sec": int,
|
|
281
|
+
"mode": NotRequired[str],
|
|
282
|
+
"valid": NotRequired[int],
|
|
283
|
+
},
|
|
284
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: eheimdigital
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Offers a Python API for the EHEIM Digital smart aquarium devices.
|
|
5
|
+
Project-URL: Homepage, https://github.com/autinerd/eheimdigital
|
|
6
|
+
License: MIT License
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Dist: aiohttp
|
|
11
|
+
Requires-Dist: yarl
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
eheimdigital/__init__.py,sha256=YUvqMGJgCX2er7k9uZGXErWFIgSsualBVSWAODtnhyI,56
|
|
2
|
+
eheimdigital/classic_led_ctrl.py,sha256=GV2h27ywfPmUm87CM-K_pF4nbtDTCQ01My9FpAcPqTs,4822
|
|
3
|
+
eheimdigital/classic_vario.py,sha256=KyOYcqetTavDLWzR502Nblakt381qn6wsrGXjW6qibw,3460
|
|
4
|
+
eheimdigital/device.py,sha256=Rme9McxQ1XK_DbPxCWgciWWtr4038fqqf5owLj-3Tig,1657
|
|
5
|
+
eheimdigital/heater.py,sha256=mvWqX0rCew9BKs1_KJ_vaobmFyrymH602xbgCHPO1zA,3320
|
|
6
|
+
eheimdigital/hub.py,sha256=o3M102eTo9fAA3mjQh69lW21BMoC_03vQRQL2K4zw00,6185
|
|
7
|
+
eheimdigital/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
eheimdigital/types.py,sha256=SxckYZVEqNl9PBVFK-V_XOe4CzbMly5e_AQdTe2JxHg,6617
|
|
9
|
+
eheimdigital-1.0.1.dist-info/METADATA,sha256=go_hg4kUTiK0Srvq5gT1Dj5OuVJ4qeOp0rI_XuBPM3c,380
|
|
10
|
+
eheimdigital-1.0.1.dist-info/WHEEL,sha256=fl6v0VwpzfGBVsGtkAkhILUlJxROXbA3HvRL6Fe3140,105
|
|
11
|
+
eheimdigital-1.0.1.dist-info/licenses/LICENSE,sha256=8_SOcHL0FUe_0x_fQ_wQjA8F-5X5o6-zESDFTdQqYjI,1065
|
|
12
|
+
eheimdigital-1.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 autinerd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|