python-jebao 0.1.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.
jebao/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """Python library for controlling Jebao aquarium pumps."""
2
+
3
+ from .device import JebaoDevice
4
+ from .mdp20000 import MDP20000Device
5
+ from .discovery import JebaoDiscovery, DiscoveredDevice, discover_devices
6
+ from .exceptions import (
7
+ JebaoError,
8
+ JebaoConnectionError,
9
+ JebaoAuthenticationError,
10
+ JebaoCommandError,
11
+ JebaoTimeoutError,
12
+ )
13
+ from .const import (
14
+ DeviceState,
15
+ MODEL_MDP20000,
16
+ MODEL_MD44,
17
+ )
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "JebaoDevice",
23
+ "MDP20000Device",
24
+ "JebaoDiscovery",
25
+ "DiscoveredDevice",
26
+ "discover_devices",
27
+ "JebaoError",
28
+ "JebaoConnectionError",
29
+ "JebaoAuthenticationError",
30
+ "JebaoCommandError",
31
+ "JebaoTimeoutError",
32
+ "DeviceState",
33
+ "MODEL_MDP20000",
34
+ "MODEL_MD44",
35
+ ]
jebao/const.py ADDED
@@ -0,0 +1,68 @@
1
+ """Constants for Jebao library."""
2
+ from enum import IntEnum
3
+
4
+ # Network ports
5
+ TCP_PORT = 12416
6
+ UDP_DISCOVERY_PORT = 12414
7
+ UDP_LISTEN_PORT = 2415
8
+
9
+ # Timeouts (seconds)
10
+ DEFAULT_TIMEOUT = 5.0
11
+ DISCOVERY_TIMEOUT = 2.0
12
+ COMMAND_TIMEOUT = 5.0
13
+
14
+ # Keep-alive interval (seconds)
15
+ KEEPALIVE_INTERVAL = 4.0
16
+
17
+ # Message types
18
+ MSG_REQUEST_PASSCODE = 0x06
19
+ MSG_PASSCODE_RESPONSE = 0x07
20
+ MSG_LOGIN_REQUEST = 0x08
21
+ MSG_LOGIN_SUCCESS = 0x09
22
+ MSG_PING = 0x15
23
+ MSG_PONG = 0x16
24
+ MSG_DATA_REQUEST_SIMPLE = 0x90
25
+ MSG_DATA_RESPONSE_SIMPLE = 0x91
26
+ MSG_CONTROL_OR_EXTENDED_REQUEST = 0x93
27
+ MSG_CONTROL_RESPONSE_OR_EXTENDED_DATA = 0x94
28
+
29
+ # Command sizes
30
+ CONTROL_COMMAND_SIZE = 323
31
+
32
+ # Product keys
33
+ PRODUCT_KEY_MDP20000 = "04020B28"
34
+ PRODUCT_KEY_MD44 = "04020B27" # Assumed, verify if needed
35
+
36
+ # Model identifiers
37
+ MODEL_MDP20000 = "MDP-20000"
38
+ MODEL_MD44 = "MD-4.4"
39
+
40
+
41
+ class DeviceState(IntEnum):
42
+ """Device state values."""
43
+
44
+ OFF = 0x10 # Manual mode, pump stopped
45
+ ON = 0x11 # Manual mode, pump running
46
+ FEED = 0x15 # Feed mode, temporary pause
47
+ PROGRAM = 0x19 # Program mode, scheduled operation
48
+
49
+
50
+ # Command opcodes (at byte 21 in control messages)
51
+ class CommandOpcode(IntEnum):
52
+ """Command opcodes for control messages."""
53
+
54
+ TURN_ON_OFF = 0x01 # Byte 22: 0x01=ON, 0x00=OFF
55
+ SET_SPEED = 0x20 # Byte 23: speed 30-100
56
+ EXIT_PROGRAM = 0x28 # Exit Program mode
57
+ SET_FEED_DURATION = 0x44 # Byte 24: duration 1-10 minutes
58
+ START_FEED = 0x4C # Start feed mode
59
+ CANCEL_FEED = 0x6C # Cancel feed mode
60
+
61
+
62
+ # Speed limits
63
+ MIN_SPEED = 30
64
+ MAX_SPEED = 100
65
+
66
+ # Feed duration limits (minutes)
67
+ MIN_FEED_DURATION = 1
68
+ MAX_FEED_DURATION = 10
jebao/device.py ADDED
@@ -0,0 +1,139 @@
1
+ """Base device class for Jebao pumps."""
2
+ import asyncio
3
+ import logging
4
+ from typing import Optional
5
+
6
+ from .const import DEFAULT_TIMEOUT, DeviceState
7
+ from .exceptions import JebaoConnectionError
8
+ from .protocol import JebaoProtocol
9
+
10
+ _LOGGER = logging.getLogger(__name__)
11
+
12
+
13
+ class JebaoDevice:
14
+ """Base class for Jebao devices."""
15
+
16
+ def __init__(
17
+ self,
18
+ host: str,
19
+ port: int = 12416,
20
+ device_id: Optional[str] = None,
21
+ model: Optional[str] = None,
22
+ ):
23
+ """Initialize device.
24
+
25
+ Args:
26
+ host: Device IP address
27
+ port: TCP port (default 12416)
28
+ device_id: Device ID (from discovery)
29
+ model: Device model
30
+ """
31
+ self.host = host
32
+ self.port = port
33
+ self.device_id = device_id
34
+ self.model = model
35
+
36
+ self._protocol = JebaoProtocol(host, port)
37
+ self._state: Optional[DeviceState] = None
38
+ self._speed: Optional[int] = None
39
+ self._last_update: Optional[float] = None
40
+
41
+ @property
42
+ def is_connected(self) -> bool:
43
+ """Check if device is connected."""
44
+ return self._protocol.is_connected
45
+
46
+ @property
47
+ def state(self) -> Optional[DeviceState]:
48
+ """Get current device state."""
49
+ return self._state
50
+
51
+ @property
52
+ def speed(self) -> Optional[int]:
53
+ """Get current speed percentage (30-100)."""
54
+ return self._speed
55
+
56
+ @property
57
+ def is_on(self) -> bool:
58
+ """Check if device is on (running or in feed mode)."""
59
+ return self._state in (DeviceState.ON, DeviceState.FEED)
60
+
61
+ @property
62
+ def is_off(self) -> bool:
63
+ """Check if device is off."""
64
+ return self._state == DeviceState.OFF
65
+
66
+ @property
67
+ def is_feed_mode(self) -> bool:
68
+ """Check if device is in feed mode."""
69
+ return self._state == DeviceState.FEED
70
+
71
+ @property
72
+ def is_program_mode(self) -> bool:
73
+ """Check if device is in program mode."""
74
+ return self._state == DeviceState.PROGRAM
75
+
76
+ @property
77
+ def is_manual_mode(self) -> bool:
78
+ """Check if device is in manual mode (ON or OFF)."""
79
+ return self._state in (DeviceState.ON, DeviceState.OFF)
80
+
81
+ async def connect(self, timeout: float = DEFAULT_TIMEOUT) -> None:
82
+ """Connect to device.
83
+
84
+ Args:
85
+ timeout: Connection timeout
86
+
87
+ Raises:
88
+ JebaoConnectionError: Connection failed
89
+ JebaoAuthenticationError: Authentication failed
90
+ """
91
+ await self._protocol.connect(timeout)
92
+ _LOGGER.info("Connected to %s (%s)", self.host, self.model or "Unknown")
93
+
94
+ # Get initial status
95
+ await self.update()
96
+
97
+ async def disconnect(self) -> None:
98
+ """Disconnect from device."""
99
+ await self._protocol.disconnect()
100
+ _LOGGER.info("Disconnected from %s", self.host)
101
+
102
+ async def update(self, timeout: float = DEFAULT_TIMEOUT) -> None:
103
+ """Update device status.
104
+
105
+ Args:
106
+ timeout: Request timeout
107
+
108
+ Raises:
109
+ JebaoConnectionError: Not connected
110
+ JebaoTimeoutError: Request timed out
111
+ """
112
+ import time
113
+
114
+ if not self.is_connected:
115
+ raise JebaoConnectionError("Not connected")
116
+
117
+ response = await self._protocol.request_status(timeout)
118
+ status = self._protocol.parse_status(response)
119
+
120
+ if status:
121
+ self._state = DeviceState(status["state"])
122
+ self._speed = status["speed"]
123
+ self._last_update = time.time()
124
+ _LOGGER.debug(
125
+ "Status updated: state=%s, speed=%d%%",
126
+ self._state.name,
127
+ self._speed,
128
+ )
129
+ else:
130
+ _LOGGER.warning("Failed to parse status response")
131
+
132
+ async def __aenter__(self):
133
+ """Async context manager entry."""
134
+ await self.connect()
135
+ return self
136
+
137
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
138
+ """Async context manager exit."""
139
+ await self.disconnect()
jebao/discovery.py ADDED
@@ -0,0 +1,399 @@
1
+ """UDP discovery for Jebao devices with multi-subnet support."""
2
+ import asyncio
3
+ import logging
4
+ import socket
5
+ from dataclasses import dataclass
6
+ from typing import List, Optional, Set
7
+
8
+ import netifaces
9
+
10
+ from .const import (
11
+ DISCOVERY_TIMEOUT,
12
+ MODEL_MD44,
13
+ MODEL_MDP20000,
14
+ PRODUCT_KEY_MD44,
15
+ PRODUCT_KEY_MDP20000,
16
+ UDP_DISCOVERY_PORT,
17
+ UDP_LISTEN_PORT,
18
+ )
19
+ from .exceptions import JebaoError
20
+
21
+ _LOGGER = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class DiscoveredDevice:
26
+ """Represents a discovered Jebao device."""
27
+
28
+ device_id: str
29
+ mac_address: str
30
+ product_key: str
31
+ ip_address: str
32
+ model: str
33
+ firmware_version: Optional[str] = None
34
+
35
+ @property
36
+ def is_mdp20000(self) -> bool:
37
+ """Check if device is MDP-20000."""
38
+ return self.model == MODEL_MDP20000
39
+
40
+ @property
41
+ def is_md44(self) -> bool:
42
+ """Check if device is MD-4.4."""
43
+ return self.model == MODEL_MD44
44
+
45
+
46
+ class JebaoDiscovery:
47
+ """UDP discovery for Jebao devices with multi-subnet support."""
48
+
49
+ # Discovery request payload
50
+ DISCOVERY_REQUEST = bytes.fromhex("00 00 00 03 03 00 00 03")
51
+
52
+ def __init__(self):
53
+ """Initialize discovery."""
54
+ self._discovered: Set[str] = set() # Track device IDs
55
+
56
+ async def discover(
57
+ self,
58
+ timeout: float = DISCOVERY_TIMEOUT,
59
+ interfaces: Optional[List[str]] = None,
60
+ ) -> List[DiscoveredDevice]:
61
+ """Discover Jebao devices on network.
62
+
63
+ This method broadcasts discovery requests on all available network
64
+ interfaces (or specified interfaces) and collects responses.
65
+
66
+ Args:
67
+ timeout: Discovery timeout in seconds
68
+ interfaces: Optional list of network interface names to use.
69
+ If None, all interfaces will be used.
70
+
71
+ Returns:
72
+ List of discovered devices
73
+
74
+ Example:
75
+ >>> discovery = JebaoDiscovery()
76
+ >>> devices = await discovery.discover()
77
+ >>> for device in devices:
78
+ ... print(f"Found {device.model} at {device.ip_address}")
79
+ """
80
+ self._discovered.clear()
81
+
82
+ # Get network interfaces to scan
83
+ if interfaces is None:
84
+ interfaces = self._get_all_interfaces()
85
+ else:
86
+ # Validate specified interfaces exist
87
+ available = self._get_all_interfaces()
88
+ interfaces = [iface for iface in interfaces if iface in available]
89
+
90
+ if not interfaces:
91
+ _LOGGER.warning("No network interfaces found for discovery")
92
+ return []
93
+
94
+ _LOGGER.info(
95
+ "Starting discovery on %d interface(s): %s",
96
+ len(interfaces),
97
+ ", ".join(interfaces),
98
+ )
99
+
100
+ # Create discovery tasks for each interface
101
+ tasks = []
102
+ for interface in interfaces:
103
+ task = asyncio.create_task(
104
+ self._discover_on_interface(interface, timeout)
105
+ )
106
+ tasks.append(task)
107
+
108
+ # Wait for all discovery tasks to complete
109
+ results = await asyncio.gather(*tasks, return_exceptions=True)
110
+
111
+ # Combine results from all interfaces
112
+ devices: List[DiscoveredDevice] = []
113
+ for result in results:
114
+ if isinstance(result, Exception):
115
+ _LOGGER.error("Discovery task failed: %s", result)
116
+ elif isinstance(result, list):
117
+ devices.extend(result)
118
+
119
+ # Deduplicate by device ID
120
+ unique_devices = {device.device_id: device for device in devices}
121
+ devices = list(unique_devices.values())
122
+
123
+ _LOGGER.info("Discovery complete: found %d device(s)", len(devices))
124
+ return devices
125
+
126
+ async def _discover_on_interface(
127
+ self, interface: str, timeout: float
128
+ ) -> List[DiscoveredDevice]:
129
+ """Discover devices on a specific network interface.
130
+
131
+ Args:
132
+ interface: Network interface name (e.g., 'eth0', 'eth1')
133
+ timeout: Discovery timeout
134
+
135
+ Returns:
136
+ List of discovered devices on this interface
137
+ """
138
+ devices: List[DiscoveredDevice] = []
139
+
140
+ # Get broadcast address for interface
141
+ broadcast_addr = self._get_broadcast_address(interface)
142
+ if not broadcast_addr:
143
+ _LOGGER.warning(
144
+ "Could not determine broadcast address for %s", interface
145
+ )
146
+ return devices
147
+
148
+ _LOGGER.debug(
149
+ "Discovering on %s (broadcast: %s)", interface, broadcast_addr
150
+ )
151
+
152
+ # Create UDP socket bound to this interface
153
+ sock = None
154
+ try:
155
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
156
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
157
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
158
+
159
+ # Bind to specific interface's IP to ensure broadcast goes out correct interface
160
+ # Use port 37479 to match what the official app uses
161
+ local_ip = self._get_interface_ip(interface)
162
+ if local_ip:
163
+ sock.bind((local_ip, 37479))
164
+ else:
165
+ sock.bind(("", 37479))
166
+
167
+ sock.setblocking(False)
168
+
169
+ # Send broadcast
170
+ _LOGGER.debug(
171
+ "Sending discovery request to %s:%d from %s",
172
+ broadcast_addr,
173
+ UDP_DISCOVERY_PORT,
174
+ local_ip or "any",
175
+ )
176
+ sock.sendto(
177
+ self.DISCOVERY_REQUEST, (broadcast_addr, UDP_DISCOVERY_PORT)
178
+ )
179
+ _LOGGER.debug("Discovery request sent: %s", self.DISCOVERY_REQUEST.hex())
180
+
181
+ # Listen for responses
182
+ end_time = asyncio.get_event_loop().time() + timeout
183
+ while asyncio.get_event_loop().time() < end_time:
184
+ remaining = end_time - asyncio.get_event_loop().time()
185
+ if remaining <= 0:
186
+ break
187
+
188
+ try:
189
+ # Use asyncio to avoid blocking
190
+ loop = asyncio.get_event_loop()
191
+ data, addr = await asyncio.wait_for(
192
+ loop.sock_recvfrom(sock, 2048), timeout=remaining
193
+ )
194
+
195
+ _LOGGER.debug(
196
+ "Received %d bytes from %s on %s",
197
+ len(data),
198
+ addr[0],
199
+ interface,
200
+ )
201
+
202
+ device = self._parse_discovery_response(data, addr[0])
203
+ if device and device.device_id not in self._discovered:
204
+ self._discovered.add(device.device_id)
205
+ devices.append(device)
206
+ _LOGGER.info(
207
+ "Discovered %s (%s) at %s on %s",
208
+ device.model,
209
+ device.device_id,
210
+ device.ip_address,
211
+ interface,
212
+ )
213
+
214
+ except asyncio.TimeoutError:
215
+ # Timeout waiting for response, discovery complete
216
+ _LOGGER.debug("No more responses on %s (timeout)", interface)
217
+ break
218
+ except Exception as err:
219
+ _LOGGER.debug("Error receiving discovery response: %s", err)
220
+ break
221
+
222
+ _LOGGER.debug("Discovery loop complete on %s, found %d devices", interface, len(devices))
223
+
224
+ except Exception as err:
225
+ _LOGGER.error("Discovery failed on %s: %s", interface, err)
226
+ finally:
227
+ if sock:
228
+ sock.close()
229
+
230
+ return devices
231
+
232
+ @staticmethod
233
+ def _get_all_interfaces() -> List[str]:
234
+ """Get all available network interfaces.
235
+
236
+ Returns:
237
+ List of interface names
238
+ """
239
+ interfaces = []
240
+ try:
241
+ for iface in netifaces.interfaces():
242
+ # Skip loopback
243
+ if iface.startswith("lo"):
244
+ continue
245
+
246
+ # Check if interface has IPv4 address
247
+ addrs = netifaces.ifaddresses(iface)
248
+ if netifaces.AF_INET in addrs:
249
+ interfaces.append(iface)
250
+
251
+ except Exception as err:
252
+ _LOGGER.error("Error enumerating interfaces: %s", err)
253
+
254
+ return interfaces
255
+
256
+ @staticmethod
257
+ def _get_interface_ip(interface: str) -> Optional[str]:
258
+ """Get IPv4 address of interface.
259
+
260
+ Args:
261
+ interface: Interface name
262
+
263
+ Returns:
264
+ IP address or None
265
+ """
266
+ try:
267
+ addrs = netifaces.ifaddresses(interface)
268
+ if netifaces.AF_INET in addrs:
269
+ return addrs[netifaces.AF_INET][0]["addr"]
270
+ except Exception as err:
271
+ _LOGGER.debug("Error getting IP for %s: %s", interface, err)
272
+
273
+ return None
274
+
275
+ @staticmethod
276
+ def _get_broadcast_address(interface: str) -> Optional[str]:
277
+ """Get broadcast address for interface.
278
+
279
+ Args:
280
+ interface: Interface name
281
+
282
+ Returns:
283
+ Broadcast address or None
284
+ """
285
+ try:
286
+ addrs = netifaces.ifaddresses(interface)
287
+ if netifaces.AF_INET in addrs:
288
+ # Some systems provide broadcast directly
289
+ if "broadcast" in addrs[netifaces.AF_INET][0]:
290
+ return addrs[netifaces.AF_INET][0]["broadcast"]
291
+
292
+ # Calculate broadcast from IP and netmask
293
+ ip = addrs[netifaces.AF_INET][0]["addr"]
294
+ netmask = addrs[netifaces.AF_INET][0].get("netmask")
295
+
296
+ if ip and netmask:
297
+ # Convert to integers
298
+ ip_int = int.from_bytes(
299
+ socket.inet_aton(ip), byteorder="big"
300
+ )
301
+ mask_int = int.from_bytes(
302
+ socket.inet_aton(netmask), byteorder="big"
303
+ )
304
+
305
+ # Calculate broadcast
306
+ broadcast_int = ip_int | (~mask_int & 0xFFFFFFFF)
307
+ broadcast = socket.inet_ntoa(
308
+ broadcast_int.to_bytes(4, byteorder="big")
309
+ )
310
+ return broadcast
311
+
312
+ except Exception as err:
313
+ _LOGGER.debug(
314
+ "Error getting broadcast address for %s: %s", interface, err
315
+ )
316
+
317
+ return None
318
+
319
+ @staticmethod
320
+ def _parse_discovery_response(
321
+ data: bytes, ip_address: str
322
+ ) -> Optional[DiscoveredDevice]:
323
+ """Parse discovery response packet.
324
+
325
+ Args:
326
+ data: Raw response data (127 bytes)
327
+ ip_address: Source IP address
328
+
329
+ Returns:
330
+ DiscoveredDevice or None if parsing failed
331
+ """
332
+ try:
333
+ if len(data) < 46:
334
+ _LOGGER.debug("Response too short: %d bytes", len(data))
335
+ return None
336
+
337
+ # Verify header
338
+ if data[0:4] != b"\x00\x00\x00\x03":
339
+ _LOGGER.debug("Invalid header")
340
+ return None
341
+
342
+ # Extract fields with length-prefixed parsing
343
+ # Device ID: 2-byte length at 8-9, then string data
344
+ device_id_length = int.from_bytes(data[8:10], "big")
345
+ device_id_start = 10
346
+ device_id_end = device_id_start + device_id_length
347
+ device_id = data[device_id_start:device_id_end].decode("ascii")
348
+
349
+ # MAC address follows device ID (6 bytes)
350
+ mac_start = device_id_end + 2 # Skip 2-byte separator
351
+ mac_bytes = data[mac_start : mac_start + 6]
352
+ mac_address = ":".join(f"{b:02x}" for b in mac_bytes)
353
+
354
+ # Product key is 8 bytes as ASCII, after device_id + MAC + separators
355
+ # After device_id (var len) + separator (2) + MAC (6) + separator (2)
356
+ product_key_start = device_id_end + 2 + 6 + 2
357
+ product_key = data[product_key_start : product_key_start + 8].rstrip(b"\x00").decode("ascii")
358
+
359
+ # Determine model from product key
360
+ if product_key == PRODUCT_KEY_MDP20000:
361
+ model = MODEL_MDP20000
362
+ elif product_key == PRODUCT_KEY_MD44:
363
+ model = MODEL_MD44
364
+ else:
365
+ _LOGGER.warning("Unknown product key: %s", product_key)
366
+ model = f"Unknown ({product_key})"
367
+
368
+ return DiscoveredDevice(
369
+ device_id=device_id,
370
+ mac_address=mac_address,
371
+ product_key=product_key,
372
+ ip_address=ip_address,
373
+ model=model,
374
+ )
375
+
376
+ except Exception as err:
377
+ _LOGGER.error("Error parsing discovery response: %s", err)
378
+ return None
379
+
380
+
381
+ async def discover_devices(
382
+ timeout: float = DISCOVERY_TIMEOUT,
383
+ interfaces: Optional[List[str]] = None,
384
+ ) -> List[DiscoveredDevice]:
385
+ """Convenience function to discover devices.
386
+
387
+ Args:
388
+ timeout: Discovery timeout in seconds
389
+ interfaces: Optional list of interface names to scan
390
+
391
+ Returns:
392
+ List of discovered devices
393
+
394
+ Example:
395
+ >>> devices = await discover_devices()
396
+ >>> mdp20000_devices = [d for d in devices if d.is_mdp20000]
397
+ """
398
+ discovery = JebaoDiscovery()
399
+ return await discovery.discover(timeout=timeout, interfaces=interfaces)
jebao/exceptions.py ADDED
@@ -0,0 +1,25 @@
1
+ """Exceptions for Jebao library."""
2
+
3
+
4
+ class JebaoError(Exception):
5
+ """Base exception for Jebao library."""
6
+
7
+
8
+ class JebaoConnectionError(JebaoError):
9
+ """Connection error."""
10
+
11
+
12
+ class JebaoAuthenticationError(JebaoError):
13
+ """Authentication failed."""
14
+
15
+
16
+ class JebaoCommandError(JebaoError):
17
+ """Command execution failed."""
18
+
19
+
20
+ class JebaoTimeoutError(JebaoError):
21
+ """Operation timed out."""
22
+
23
+
24
+ class JebaoInvalidStateError(JebaoError):
25
+ """Device is in invalid state for operation."""