"""API client for HTTP communication with Indevolt devices.""" import asyncio import json import logging from typing import Any, Callable import aiohttp from aiohttp import ClientError from .const import ( ACTIVE_DISCOVERY_MESSAGE, ACTIVE_DISCOVERY_PORT, ACTIVE_DISCOVERY_TIMEOUT, DEVICE_LIMITS, IndevoltRealtimeAction, PASSIVE_DISCOVERY_MAGIC, SET_REALTIME_ACTION, _ACTIVE_BROADCAST_ADDR, ) _LOGGER = logging.getLogger(__name__) class PowerExceedsMaxError(Exception): """Raised when requested (dis)charge power exceeds the device maximum.""" def __init__(self, power: int, max_power: int, generation: int) -> None: self.power = power self.max_power = max_power self.generation = generation class SocBelowMinimumError(Exception): """Raised when target SOC is below the API's hard minimum.""" def __init__(self, target_soc: int, minimum_soc: int, generation: int) -> None: self.target_soc = target_soc self.minimum_soc = minimum_soc self.generation = generation class DiscoveredDevice: """Represents a discovered Indevolt device.""" def __init__( self, host: str, port: int = 8080, name: str | None = None, **kwargs: Any ) -> None: """Initialize discovered device. Args: host: Device IP address port: Device port (default: 8080) name: Device name if available **kwargs: Additional device metadata """ self.host = host self.port = port self.name = name self.metadata = kwargs def __repr__(self) -> str: """Return string representation.""" return f"DiscoveredDevice(host={self.host!r}, port={self.port}, name={self.name!r})" class PassiveDiscoveryProtocol(asyncio.DatagramProtocol): """Listens passively for unsolicited device broadcasts on UDP port 8099. Devices periodically broadcast a ``BCF-D``-prefixed packet on the local network. Bind this protocol to :data:`~indevolt_api.PASSIVE_DISCOVERY_PORT` (``8099``) to receive those announcements without sending any traffic. """ MAGIC: bytes = PASSIVE_DISCOVERY_MAGIC def __init__(self, callback: Callable[[str], None]) -> None: """Initialize the passive discovery protocol. Args: callback: Callable invoked with the device's IP address whenever a valid broadcast is received. """ self._callback = callback def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: """Handle an incoming UDP datagram. Ignores packets that do not start with :data:`~indevolt_api.PASSIVE_DISCOVERY_MAGIC`. Args: data: Raw bytes received. addr: Sender address as ``(host, port)``. """ if data.startswith(self.MAGIC): self._callback(addr[0]) class ActiveDiscoveryProtocol(asyncio.DatagramProtocol): """Protocol to handle UDP discovery responses from Indevolt devices.""" def __init__(self) -> None: """Initialize discovery protocol.""" self.transport: asyncio.transports.DatagramTransport | None = None self.devices: list[DiscoveredDevice] = [] self.received_ips: set[str] = set() def connection_made(self, transport: asyncio.transports.BaseTransport) -> None: """Handle connection establishment. Args: transport: UDP transport """ self.transport = transport def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: """Handle incoming UDP datagram from device. Args: data: Received data bytes addr: Sender address (host, port) """ host = addr[0] # Avoid duplicate discoveries if host in self.received_ips: return try: response = json.loads(data.decode("utf-8")) except (json.JSONDecodeError, UnicodeDecodeError): device = DiscoveredDevice(host=host) else: device = DiscoveredDevice( host=host, port=response.get("port", 8080), name=response.get("name"), **{k: v for k, v in response.items() if k not in ["port", "name"]}, ) self.devices.append(device) self.received_ips.add(host) async def async_discover( timeout: float = ACTIVE_DISCOVERY_TIMEOUT, ) -> list[DiscoveredDevice]: """Discover Indevolt devices on the local network. Sends :data:`~indevolt_api.ACTIVE_DISCOVERY_MESSAGE` (``AT+IGDEVICEIP``) via UDP broadcast to :data:`~indevolt_api.PASSIVE_DISCOVERY_PORT` (``8099``). Devices on the same network will respond to :data:`~indevolt_api.ACTIVE_DISCOVERY_PORT` (``10000``) with their IP and optional metadata (port, name, etc.) in JSON format. The broadcast is sent to the subnet broadcast address of every local interface in addition to ``255.255.255.255``, so the packet reliably reaches the physical network even when virtual adapters (e.g. Docker) alter OS broadcast routing. Args: timeout: Discovery timeout in seconds (default: 5.0) Returns: List of discovered devices Example: >>> devices = await async_discover(timeout=3.0) >>> for device in devices: ... print(f"Found device at {device.host}:{device.port}") """ import socket as _socket loop = asyncio.get_running_loop() protocol = ActiveDiscoveryProtocol() try: transport, _ = await loop.create_datagram_endpoint( lambda: protocol, local_addr=("0.0.0.0", ACTIVE_DISCOVERY_PORT), allow_broadcast=True, ) except OSError as e: _LOGGER.error( "Failed to bind to discovery port %d: %s", ACTIVE_DISCOVERY_PORT, e ) return [] # Send to 255.255.255.255 AND to every local interface's subnet broadcast. # This ensures the packet travels over the physical NIC when Docker or other # virtual adapters would otherwise intercept the limited broadcast. broadcast_port = _ACTIVE_BROADCAST_ADDR[1] send_targets: set[tuple[str, int]] = {_ACTIVE_BROADCAST_ADDR} try: for ip in _socket.gethostbyname_ex(_socket.gethostname())[2]: if not ip.startswith("127.") and not ip.startswith("169.254."): subnet_bcast = ip.rsplit(".", 1)[0] + ".255" send_targets.add((subnet_bcast, broadcast_port)) except Exception: pass try: for target in send_targets: try: transport.sendto(ACTIVE_DISCOVERY_MESSAGE, target) except Exception as e: _LOGGER.debug("Discovery broadcast to %s failed: %s", target, e) await asyncio.sleep(timeout) return protocol.devices except Exception as e: _LOGGER.error("Indevolt device discovery failed: %s", e) return [] finally: transport.close() class IndevoltAPI: """Handle all HTTP communication with Indevolt devices.""" def __init__( self, host: str, port: int, session: aiohttp.ClientSession, timeout: float = 10.0, ) -> None: """Initialize the Indevolt API client. Args: host: Device hostname or IP address port: Device port number session: aiohttp ClientSession for HTTP requests timeout: Request timeout in seconds (default: 10.0) """ self.host = host self.port = port self.session = session self.base_url = f"http://{host}:{port}/rpc" self.timeout = aiohttp.ClientTimeout(total=timeout) @classmethod def from_discovered_device( cls, device: DiscoveredDevice, session: aiohttp.ClientSession, timeout: float = 10.0, ) -> "IndevoltAPI": """Create API client from a discovered device. Args: device: Discovered device object session: aiohttp ClientSession for HTTP requests timeout: Request timeout in seconds (default: 10.0) Returns: IndevoltAPI instance configured for the discovered device Example: >>> devices = await async_discover() >>> if devices: ... api = IndevoltAPI.from_discovered_device(devices[0], session) ... # Or with custom timeout: ... api = IndevoltAPI.from_discovered_device(devices[0], session, timeout=10.0) """ return cls(host=device.host, port=device.port, session=session, timeout=timeout) async def _request( self, endpoint: str, config_data: dict[str, Any] ) -> dict[str, Any]: """Make HTTP request to device endpoint. Args: endpoint: RPC endpoint name (e.g., "Indevolt.GetData") config_data: Configuration data to send Returns: Device response dictionary """ config_param = json.dumps(config_data).replace(" ", "") url = f"{self.base_url}/{endpoint}?config={config_param}" try: async with self.session.post(url, timeout=self.timeout) as response: if response.status != 200: raise ClientError(f"HTTP status error: {response.status}") return await response.json() except TimeoutError: raise except aiohttp.ClientError as err: raise ClientError(f"{endpoint} Network error: {err}") from err async def fetch_data(self, t: str | list[str]) -> dict[str, Any]: """Fetch raw JSON data from the device. Args: t: cJson Point(s) to retrieve. Accepts a StrEnum member, a raw string key, or a list of either (e.g. [IndevoltSystem.INPUT_POWER, IndevoltGrid.VOLTAGE]). Returns: Device response dictionary whose keys are strings matching the requested cJson Points. StrEnum members can be used directly to index the result (e.g. data[IndevoltSystem.INPUT_POWER]). """ if not isinstance(t, list): t = [t] t_int = [int(item) for item in t] return await self._request("Indevolt.GetData", {"t": t_int}) async def set_data(self, t: str | int, v: Any) -> bool: """Write/push data to the device. Args: t: cJson Point identifier of the API (e.g., "47015" or 47015) v: Value(s) to write (will be converted to list of integers if needed) Returns: True on success, False otherwise Example: await api.set_data(SET_REALTIME_ACTION, [IndevoltRealtimeAction.CHARGE, 700, 5]) await api.set_data(IndevoltConfig.WRITE_ENERGY_MODE, IndevoltEnergyMode.SELF_CONSUMED_PRIORITIZED) """ # Convert v to list if not already if not isinstance(v, list): v = [v] t_int = int(t) v_int = [int(item) for item in v] try: response = await self._request( "Indevolt.SetData", {"f": 16, "t": t_int, "v": v_int} ) return bool(response.get("result", False)) except (OSError, ClientError, ValueError) as err: _LOGGER.debug("SetData request failed for t=%s, v=%s: %s", t, v, err) return False async def stop(self) -> bool: """Stop any active real-time charge or discharge action. Returns True on success, False if the command was rejected. """ return await self.set_data( SET_REALTIME_ACTION, [IndevoltRealtimeAction.STOP, 0, 0], ) async def charge(self, power: int, target_soc: int) -> bool: """Send a real-time charge command to the device. Returns True on success, False if the command was rejected. Raises ValueError if power or target_soc are out of range. """ return await self.set_data( SET_REALTIME_ACTION, [IndevoltRealtimeAction.CHARGE, power, target_soc], ) async def discharge(self, power: int, target_soc: int) -> bool: """Send a real-time discharge command to the device. Returns True on success, False if the command was rejected. Raises ValueError if power or target_soc are out of range. """ return await self.set_data( SET_REALTIME_ACTION, [IndevoltRealtimeAction.DISCHARGE, power, target_soc], ) def check_charge_limits(self, power: int, target_soc: int, generation: int) -> None: """Check that charge parameters do not exceed device limits. Args: power: Requested charge power in watts target_soc: Target state of charge percentage generation: Device hardware generation (1 or 2) Raises: PowerExceedsMaxError: If power exceeds the device maximum SocBelowMinimumError: If target_soc is below the device minimum """ max_power = DEVICE_LIMITS[generation]["max_charge_power"] if power > max_power: raise PowerExceedsMaxError(power, max_power, generation) min_soc = DEVICE_LIMITS[generation]["minimum_soc"] if target_soc < min_soc: raise SocBelowMinimumError(target_soc, min_soc, generation) def check_discharge_limits( self, power: int, target_soc: int, generation: int ) -> None: """Check that discharge parameters do not exceed device limits. Args: power: Requested discharge power in watts target_soc: Target state of charge percentage generation: Device hardware generation (1 or 2) Raises: PowerExceedsMaxError: If power exceeds the device maximum SocBelowMinimumError: If target_soc is below the device minimum """ max_power = DEVICE_LIMITS[generation]["max_discharge_power"] if power > max_power: raise PowerExceedsMaxError(power, max_power, generation) min_soc = DEVICE_LIMITS[generation]["minimum_soc"] if target_soc < min_soc: raise SocBelowMinimumError(target_soc, min_soc, generation) async def get_config(self) -> dict[str, Any]: """Get system configuration from the device. Returns: Device system configuration dictionary """ url = f"{self.base_url}/Sys.GetConfig" try: async with self.session.get(url, timeout=self.timeout) as response: if response.status != 200: raise ClientError(f"HTTP status error: {response.status}") data = await response.json() # Enrich response with device generation if "device" in data and "type" in data["device"]: device_type = data["device"]["type"] data["device"]["generation"] = ( 2 if device_type in [ "CMS-SP2000", "CMS-SF2000", "CMS-SP3000", "CMS-SF3000", "CMS-SF1200", ] else 1 ) return data except TimeoutError as err: raise TimeoutError("Sys.GetConfig Request timed out") from err except aiohttp.ClientError as err: raise ClientError(f"Sys.GetConfig Network error: {err}") from err