"""API client for the Sternwarte Web-IO.""" from __future__ import annotations from dataclasses import dataclass import asyncio import time from .models import SternwarteState @dataclass(slots=True) class SternwarteApi: """Thin Web-IO client.""" host: str port: int password: str def _url(self, path: str) -> str: separator = "&" if "?" in path else "?" nonce = time.monotonic_ns() return f"http://{self.host}:{self.port}{path}{separator}_ts={nonce}" async def _get_text(self, path: str) -> str: request_path = self._url(path).split(f"{self.host}:{self.port}", 1)[-1] request = ( f"GET {request_path} HTTP/1.0\r\n" f"Host: {self.host}\r\n" "Connection: close\r\n" "\r\n" ).encode("ascii") last_error: Exception | None = None for attempt in range(2): try: reader, writer = await asyncio.wait_for( asyncio.open_connection(self.host, self.port), timeout=5, ) try: writer.write(request) await asyncio.wait_for(writer.drain(), timeout=5) raw = await asyncio.wait_for(reader.read(4096), timeout=5) content_length = None if b"\r\n\r\n" in raw: headers, initial_body = raw.split(b"\r\n\r\n", 1) for header_line in headers.decode("iso-8859-1", errors="ignore").split("\r\n"): if header_line.lower().startswith("content-length:"): try: content_length = int(header_line.split(":", 1)[1].strip()) except ValueError: content_length = None break else: initial_body = raw if content_length is not None: remaining = max(0, content_length - len(initial_body)) body = initial_body + ( await asyncio.wait_for(reader.readexactly(remaining), timeout=5) if remaining else b"" ) else: body = initial_body finally: writer.close() await writer.wait_closed() return body.decode("utf-8", errors="ignore").replace("\x00", "").strip() except (OSError, asyncio.TimeoutError) as err: last_error = err if attempt == 1: raise await asyncio.sleep(0.25) raise last_error if last_error is not None else RuntimeError("Unbekannter Web-IO-Fehler") async def fetch_state(self) -> SternwarteState: """Fetch and parse the current Web-IO state.""" raw = await self._get_text(f"/allout?PW={self.password}") parts = raw.split(";") state = SternwarteState() out_index = parts.index("output") if "output" in parts else -1 if out_index != -1 and out_index + 1 < len(parts): value = int(parts[out_index + 1] or "0", 16) state.outputs = [bool(value & (1 << i)) for i in range(12)] in_index = parts.index("input") if "input" in parts else -1 if in_index != -1 and in_index + 1 < len(parts): value = int(parts[in_index + 1] or "0", 16) state.inputs = [bool(value & (1 << i)) for i in range(12)] counter_index = parts.index("counter") if "counter" in parts else -1 if counter_index != -1: for i in range(min(12, len(parts) - counter_index - 1)): state.counters[i] = int(parts[counter_index + 1 + i] or "0") return state async def set_output(self, index: int, enabled: bool) -> None: """Set a digital output.""" state = "ON" if enabled else "OFF" await self._get_text(f"/outputaccess{index}?PW={self.password}&State={state}&") async def clear_counter(self, index: int) -> None: """Clear a counter.""" await self._get_text(f"/counterclear{index}?PW={self.password}&")