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 +35 -0
- jebao/const.py +68 -0
- jebao/device.py +139 -0
- jebao/discovery.py +399 -0
- jebao/exceptions.py +25 -0
- jebao/mdp20000.py +302 -0
- jebao/protocol.py +670 -0
- jebao/retry.py +77 -0
- python_jebao-0.1.0.dist-info/METADATA +367 -0
- python_jebao-0.1.0.dist-info/RECORD +13 -0
- python_jebao-0.1.0.dist-info/WHEEL +5 -0
- python_jebao-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_jebao-0.1.0.dist-info/top_level.txt +1 -0
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."""
|