eheimdigital 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.1
2
+ Name: eheimdigital
3
+ Version: 1.0.0
4
+ Summary: Offers a Python API for the EHEIM Digital smart aquarium devices.
5
+ License: MIT License
6
+ Project-URL: Homepage, https://github.com/autinerd/eheimdigital
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Programming Language :: Python :: 3
9
+ License-File: LICENSE
10
+ Requires-Dist: aiohttp
@@ -0,0 +1,23 @@
1
+ # EHEIM.digital API wrapper in Python
2
+
3
+ This library is an API wrapper for the EHEIM.digital smart aquarium tools.
4
+
5
+
6
+ ## Currently supported devices
7
+
8
+ - EHEIM classicLEDcontrol+e
9
+ - EHEIM classicVARIO
10
+ - EHEIM thermocontrol
11
+
12
+ ## How to use
13
+
14
+ ### Connect to a hub
15
+
16
+ ```python
17
+ from aiohttp import ClientSession
18
+ from eheimdigital.hub import EheimDigitalHub
19
+
20
+ session = ClientSession(base_url="http://eheimdigital")
21
+ hub = EheimDigitalHub(session)
22
+ await hub.connect()
23
+ ```
@@ -0,0 +1,3 @@
1
+ """The Eheim Digital package."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,140 @@
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
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
+ async def parse_message(self, msg: dict) -> None:
43
+ """Parse a message."""
44
+ match msg["title"]:
45
+ case MsgTitle.CCV:
46
+ self.ccv = CCVPacket(**msg)
47
+ case MsgTitle.CLOUD:
48
+ self.cloud = CloudPacket(**msg)
49
+ case MsgTitle.MOON:
50
+ self.moon = MoonPacket(**msg)
51
+ case MsgTitle.CLOCK:
52
+ self.clock = ClockPacket(**msg)
53
+
54
+ async def update(self) -> None:
55
+ """Get the new light state."""
56
+ await self.hub.send_packet(
57
+ {"title": "REQ_CCV", "to": self.mac_address, "from": "USER"}
58
+ )
59
+ await self.hub.send_packet(
60
+ {"title": "GET_CLOCK", "to": self.mac_address, "from": "USER"}
61
+ )
62
+ if "moon" not in self.__dict__:
63
+ await self.hub.send_packet(
64
+ {"title": "GET_MOON", "to": self.mac_address, "from": "USER"}
65
+ )
66
+ if "cloud" not in self.__dict__:
67
+ await self.hub.send_packet(
68
+ {"title": "GET_CLOUD", "to": self.mac_address, "from": "USER"}
69
+ )
70
+
71
+ @property
72
+ def light_level(self) -> tuple[int | None, int | None]:
73
+ """Return the current light level of the channels."""
74
+ if self.ccv is None:
75
+ return (None, None)
76
+ return (
77
+ self.ccv["currentValues"][0] if len(self.tankconfig[0]) > 0 else None,
78
+ self.ccv["currentValues"][1] if len(self.tankconfig[1]) > 0 else None,
79
+ )
80
+
81
+ @property
82
+ def power_consumption(self) -> tuple[float | None, float | None]:
83
+ """Return the power consumption of the channels."""
84
+ if self.ccv is None:
85
+ return (None, None)
86
+ return (
87
+ sum(self.power[0]) * self.ccv["currentValues"][0]
88
+ if len(self.tankconfig[0]) > 0
89
+ else None,
90
+ sum(self.power[1]) * self.ccv["currentValues"][1]
91
+ if len(self.tankconfig[1]) > 0
92
+ else None,
93
+ )
94
+
95
+ @property
96
+ def light_mode(self) -> LightMode | None:
97
+ """Return the current light operation mode."""
98
+ if self.clock is None or "mode" not in self.clock:
99
+ return None
100
+ return LightMode(self.clock["mode"])
101
+
102
+ async def set_light_mode(self, mode: LightMode) -> None:
103
+ """Set the light operation mode."""
104
+ await self.hub.send_packet(
105
+ {"title": str(mode), "to": self.mac_address, "from": "USER"}
106
+ )
107
+
108
+ async def turn_on(self, value: int, channel: int) -> None:
109
+ """Set a new brightness value for a channel."""
110
+ if self.light_mode == LightMode.DAYCL_MODE:
111
+ await self.set_light_mode(LightMode.MAN_MODE)
112
+ if self.ccv is None:
113
+ return
114
+ currentvalues = self.ccv["currentValues"]
115
+ currentvalues[channel] = value
116
+ await self.hub.send_packet(
117
+ {
118
+ "title": "CCV-SL",
119
+ "currentValues": currentvalues,
120
+ "to": self.mac_address,
121
+ "from": "USER",
122
+ }
123
+ )
124
+
125
+ async def turn_off(self, channel: int) -> None:
126
+ """Turn off a channel."""
127
+ if self.light_mode == LightMode.DAYCL_MODE:
128
+ await self.set_light_mode(LightMode.MAN_MODE)
129
+ if self.ccv is None:
130
+ return
131
+ currentvalues = self.ccv["currentValues"]
132
+ currentvalues[channel] = 0
133
+ await self.hub.send_packet(
134
+ {
135
+ "title": "CCV-SL",
136
+ "currentValues": currentvalues,
137
+ "to": self.mac_address,
138
+ "from": "USER",
139
+ }
140
+ )
@@ -0,0 +1,101 @@
1
+ """The Eheim Digital classicVARIO filter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from logging import getLogger
6
+ from typing import TYPE_CHECKING
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
+ async def parse_message(self, msg: dict) -> None:
32
+ """Parse a message."""
33
+ if msg["title"] == MsgTitle.CLASSIC_VARIO_DATA:
34
+ self.classic_vario_data = ClassicVarioDataPacket(**msg)
35
+
36
+ async def update(self) -> None:
37
+ """Get the new filter state."""
38
+ await self.hub.send_packet(
39
+ {
40
+ "title": MsgTitle.GET_CLASSIC_VARIO_DATA,
41
+ "to": self.mac_address,
42
+ "from": "USER",
43
+ }
44
+ )
45
+
46
+ async def set_classic_vario_param(self, data: dict) -> 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
+ {
55
+ "title": "SET_CLASSIC_VARIO_PARAM",
56
+ "to": self.classic_vario_data["from"],
57
+ "filterActive": self.classic_vario_data["filterActive"],
58
+ "rel_manual_motor_speed": self.classic_vario_data[
59
+ "rel_manual_motor_speed"
60
+ ],
61
+ "rel_motor_speed_day": self.classic_vario_data["rel_motor_speed_day"],
62
+ "rel_motor_speed_night": self.classic_vario_data[
63
+ "rel_motor_speed_night"
64
+ ],
65
+ "startTime_day": self.classic_vario_data["startTime_day"],
66
+ "startTime_night": self.classic_vario_data["startTime_night"],
67
+ "pulse_motorSpeed_High": self.classic_vario_data[
68
+ "pulse_motorSpeed_High"
69
+ ],
70
+ "pulse_motorSpeed_Low": self.classic_vario_data["pulse_motorSpeed_Low"],
71
+ "pulse_Time_High": self.classic_vario_data["pulse_Time_High"],
72
+ "pulse_Time_Low": self.classic_vario_data["pulse_Time_Low"],
73
+ "pumpMode": self.classic_vario_data["pumpMode"],
74
+ "from": "USER",
75
+ **data,
76
+ }
77
+ )
78
+
79
+ @property
80
+ def current_speed(self) -> int:
81
+ """Return the current filter pump speed."""
82
+ return self.classic_vario_data["rel_speed"]
83
+
84
+ @property
85
+ def service_hours(self) -> int:
86
+ """Return the amount of hours until the next service is needed."""
87
+ return self.classic_vario_data["serviceHour"]
88
+
89
+ @property
90
+ def filter_mode(self) -> FilterMode:
91
+ """Return the current filter mode."""
92
+ return FilterMode(self.classic_vario_data["pumpMode"])
93
+
94
+ async def set_filter_mode(self, value: FilterMode) -> None:
95
+ """Set the filter mode."""
96
+ await self.set_classic_vario_param({"pumpMode": value.value})
97
+
98
+ @property
99
+ def is_active(self) -> bool:
100
+ """Return whether the filter is active."""
101
+ return bool(self.classic_vario_data["filterActive"])
@@ -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
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) -> None:
54
+ """Parse a message."""
55
+
56
+ @abstractmethod
57
+ async def update(self) -> None:
58
+ """Update a device state."""
@@ -0,0 +1,99 @@
1
+ """The Eheim Digital Heater."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
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
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
+ async def update(self) -> None:
35
+ """Get the new heater state."""
36
+ await self.hub.send_packet(
37
+ {"title": MsgTitle.GET_EHEATER_DATA, "to": self.mac_address, "from": "USER"}
38
+ )
39
+
40
+ async def set_eheater_param(self, data: dict) -> None:
41
+ """Send a SET_EHEATER_PARAM packet, containing new values from data."""
42
+ await self.hub.send_packet(
43
+ {
44
+ "title": "SET_EHEATER_PARAM",
45
+ "to": self.heater_data["from"],
46
+ "mUnit": self.heater_data["mUnit"],
47
+ "sollTemp": self.heater_data["sollTemp"],
48
+ "active": self.heater_data["active"],
49
+ "hystLow": self.heater_data["hystLow"],
50
+ "hystHigh": self.heater_data["hystHigh"],
51
+ "offset": self.heater_data["offset"],
52
+ "mode": self.heater_data["mode"],
53
+ "sync": self.heater_data["sync"],
54
+ "partnerName": self.heater_data["partnerName"],
55
+ "dayStartT": self.heater_data["dayStartT"],
56
+ "nightStartT": self.heater_data["nightStartT"],
57
+ "nReduce": self.heater_data["nReduce"],
58
+ "from": "USER",
59
+ **data,
60
+ }
61
+ )
62
+
63
+ @property
64
+ def temperature_unit(self) -> HeaterUnit:
65
+ """Return the temperature unit."""
66
+ return HeaterUnit(self.heater_data["mUnit"])
67
+
68
+ @property
69
+ def current_temperature(self) -> float:
70
+ """Return the current temperature."""
71
+ return self.heater_data["isTemp"] / 10
72
+
73
+ @property
74
+ def target_temperature(self) -> float:
75
+ """Return the target temperature."""
76
+ return self.heater_data["sollTemp"] / 10
77
+
78
+ async def set_target_temperature(self, value: float) -> None:
79
+ """Set a new target temperature."""
80
+ await self.set_eheater_param({"sollTemp": int(value * 10)})
81
+
82
+ @property
83
+ def temperature_offset(self) -> float:
84
+ """Return the temperature offset."""
85
+ return self.heater_data["offset"] / 10
86
+
87
+ async def set_temperature_offset(self, value: float) -> None:
88
+ """Set a temperature offset."""
89
+ await self.set_eheater_param({"offset": int(value * 10)})
90
+
91
+ @property
92
+ def operation_mode(self) -> HeaterMode:
93
+ """Return the heater operation mode."""
94
+ return HeaterMode(self.heater_data["mode"])
95
+
96
+ @property
97
+ def is_heating(self) -> bool:
98
+ """Return whether the heater is heating."""
99
+ return bool(self.heater_data["isHeating"])
@@ -0,0 +1,145 @@
1
+ """The Eheim Digital hub."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import typing
7
+ from logging import getLogger
8
+
9
+ import aiohttp
10
+
11
+ from .classic_led_ctrl import EheimDigitalClassicLEDControl
12
+ from .classic_vario import EheimDigitalClassicVario
13
+ from .heater import EheimDigitalHeater
14
+ from .types import EheimDeviceType, MeshNetworkPacket, MsgTitle, UsrDtaPacket
15
+
16
+ if typing.TYPE_CHECKING:
17
+ from .device import EheimDigitalDevice
18
+
19
+
20
+ _LOGGER = getLogger(__package__)
21
+
22
+
23
+ class EheimDigitalHub:
24
+ """Represent a Eheim Digital hub."""
25
+
26
+ devices: dict[str, EheimDigitalDevice]
27
+ ws: aiohttp.ClientWebSocketResponse
28
+ receive_task: asyncio.Task
29
+ loop: asyncio.AbstractEventLoop
30
+ receive_callback: typing.Callable[[], typing.Awaitable[None]] | None
31
+ master: EheimDigitalDevice | None
32
+
33
+ def __init__(
34
+ self,
35
+ *,
36
+ session: aiohttp.ClientSession | None = None,
37
+ loop: asyncio.AbstractEventLoop | None = None,
38
+ receive_callback: typing.Callable[[], typing.Awaitable[None]] | None = None,
39
+ ) -> None:
40
+ """Initialize a hub."""
41
+ self.session = session or aiohttp.ClientSession(
42
+ base_url="http://eheimdigital.local"
43
+ )
44
+ self.devices = {}
45
+ self.loop = loop or asyncio.get_event_loop()
46
+ self.receive_callback = receive_callback
47
+ self.master = None
48
+
49
+ async def connect(self) -> None: # pragma: no cover
50
+ """Connect to the hub."""
51
+ self.ws = await self.session.ws_connect("/ws")
52
+ self.receive_task = self.loop.create_task(self.receive_messages())
53
+
54
+ async def close(self) -> None: # pragma: no cover
55
+ """Close the connection."""
56
+ self.receive_task.cancel()
57
+ if self.ws is not None and not self.ws.closed:
58
+ await self.ws.close()
59
+
60
+ def add_device(self, usrdta: UsrDtaPacket) -> None:
61
+ """Add a device to the device list."""
62
+ match EheimDeviceType(usrdta["version"]):
63
+ case EheimDeviceType.VERSION_EHEIM_EXT_HEATER:
64
+ self.devices[usrdta["from"]] = EheimDigitalHeater(self, usrdta)
65
+ case EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO:
66
+ self.devices[usrdta["from"]] = EheimDigitalClassicVario(self, usrdta)
67
+ case EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E:
68
+ self.devices[usrdta["from"]] = EheimDigitalClassicLEDControl(
69
+ self, usrdta
70
+ )
71
+ if self.master is None and usrdta["from"] in self.devices:
72
+ self.master = self.devices[usrdta["from"]]
73
+
74
+ async def request_usrdta(self, mac_address: str) -> None:
75
+ """Request the USRDTA of a device."""
76
+ await self.send_packet(
77
+ {"title": MsgTitle.GET_USRDTA, "to": mac_address, "from": "USER"}
78
+ )
79
+
80
+ async def send_packet(self, packet: dict) -> None:
81
+ """Send a packet to the hub."""
82
+ await self.ws.send_json(packet)
83
+
84
+ async def parse_mesh_network(self, msg: MeshNetworkPacket) -> None:
85
+ """Parse a MESH_NETWORK packet."""
86
+ for client in msg["clientList"]:
87
+ if client not in self.devices:
88
+ await self.request_usrdta(client)
89
+
90
+ async def parse_usrdta(self, msg: UsrDtaPacket) -> None:
91
+ """Parse a USRDTA packet."""
92
+ if msg["from"] not in self.devices:
93
+ self.add_device(msg)
94
+
95
+ async def parse_message(self, msg: dict) -> None:
96
+ """Parse a received message."""
97
+ if "from" not in msg:
98
+ _LOGGER.debug("Received message without 'from' property: %s", msg)
99
+ return
100
+ if "USER" in msg["from"]:
101
+ _LOGGER.debug("Received message from other user: %s", msg)
102
+ return
103
+ if "title" not in msg:
104
+ _LOGGER.debug("Received message without 'title' property: %s", msg)
105
+ return
106
+ match msg["title"]:
107
+ case MsgTitle.MESH_NETWORK:
108
+ _LOGGER.debug("Received mesh network packet: %s", msg)
109
+ await self.parse_mesh_network(MeshNetworkPacket(**msg))
110
+ case MsgTitle.USRDTA:
111
+ _LOGGER.debug("Received usrdta packet: %s", msg)
112
+ await self.parse_usrdta(UsrDtaPacket(**msg))
113
+ if self.receive_callback:
114
+ await self.receive_callback()
115
+ case _:
116
+ _LOGGER.debug(
117
+ "Received packet %s for device %s: %s",
118
+ msg["title"],
119
+ msg["from"],
120
+ msg,
121
+ )
122
+ if "from" in msg and msg["from"] in self.devices:
123
+ await self.devices[msg["from"]].parse_message(msg)
124
+ if self.receive_callback:
125
+ await self.receive_callback()
126
+
127
+ async def receive_messages(self) -> None:
128
+ """Receive messages from the hub."""
129
+ while True:
130
+ async for msg in self.ws:
131
+ if msg.type == aiohttp.WSMsgType.TEXT:
132
+ msgdata = msg.json()
133
+ if type(msgdata) is list:
134
+ for part in msgdata:
135
+ await self.parse_message(part)
136
+ else:
137
+ await self.parse_message(msgdata)
138
+
139
+ async def update(self) -> None:
140
+ """Update the device states."""
141
+ if not self.ws:
142
+ await self.connect()
143
+ await self.request_usrdta("ALL")
144
+ for device in self.devices.values():
145
+ await device.update()
@@ -0,0 +1,284 @@
1
+ """Types for Eheim Digital."""
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["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,10 @@
1
+ Metadata-Version: 2.1
2
+ Name: eheimdigital
3
+ Version: 1.0.0
4
+ Summary: Offers a Python API for the EHEIM Digital smart aquarium devices.
5
+ License: MIT License
6
+ Project-URL: Homepage, https://github.com/autinerd/eheimdigital
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Programming Language :: Python :: 3
9
+ License-File: LICENSE
10
+ Requires-Dist: aiohttp
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ eheimdigital/__init__.py
5
+ eheimdigital/classic_led_ctrl.py
6
+ eheimdigital/classic_vario.py
7
+ eheimdigital/device.py
8
+ eheimdigital/heater.py
9
+ eheimdigital/hub.py
10
+ eheimdigital/types.py
11
+ eheimdigital.egg-info/PKG-INFO
12
+ eheimdigital.egg-info/SOURCES.txt
13
+ eheimdigital.egg-info/dependency_links.txt
14
+ eheimdigital.egg-info/requires.txt
15
+ eheimdigital.egg-info/top_level.txt
16
+ tests/test_hub.py
@@ -0,0 +1 @@
1
+ aiohttp
@@ -0,0 +1 @@
1
+ eheimdigital
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "eheimdigital"
7
+ description = "Offers a Python API for the EHEIM Digital smart aquarium devices."
8
+ dynamic = ["version"]
9
+ dependencies = ["aiohttp"]
10
+ classifiers = [
11
+ "License :: OSI Approved :: MIT License",
12
+ "Programming Language :: Python :: 3",
13
+ ]
14
+ license = { text = "MIT License" }
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/autinerd/eheimdigital"
18
+
19
+ [tool.setuptools.dynamic]
20
+ version = { attr = "eheimdigital.__version__" }
21
+
22
+ [tool.pytest.ini_options]
23
+ asyncio_mode = "auto"
24
+
25
+ [tool.ruff]
26
+ output-format = "concise"
27
+
28
+ [tool.ruff.lint]
29
+ select = ["ALL"]
30
+ ignore = ["D211", "D213", "COM812", "D203", "ISC001", "E501", "EXE002"]
31
+
32
+ [tool.ruff.lint.per-file-ignores]
33
+ "tests/**" = ["S101", "RUF018"]
34
+
35
+ [tool.uv]
36
+ dev-dependencies = ["ruff>=0.6.7", "pytest>=8.3.3", "pytest-asyncio>=0.24.0"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,27 @@
1
+ """Tests for the EHEIM.digital hub."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from unittest.mock import Mock
6
+
7
+ import pytest
8
+
9
+ from eheimdigital.hub import EheimDigitalHub
10
+ from eheimdigital.types import UsrDtaPacket
11
+
12
+
13
+ @pytest.mark.parametrize("fixture", ["usrdta_heater.json"])
14
+ async def test_add_device(fixture: str) -> None:
15
+ """Tests adding a device."""
16
+ usrdta = UsrDtaPacket(
17
+ json.loads(
18
+ (Path(__file__).parent / "fixtures" / fixture).read_text(encoding="utf8")
19
+ )
20
+ )
21
+ hub = EheimDigitalHub(Mock())
22
+ hub.add_device(usrdta)
23
+ assert len(hub.devices) == 1
24
+ assert usrdta["from"] in hub.devices
25
+ assert (device := hub.devices[usrdta["from"]])
26
+ assert device.mac_address == usrdta["from"]
27
+ assert device.device_type == usrdta["version"]