Files
2026-04-27 15:15:44 +02:00

111 lines
4.3 KiB
Python

"""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}&")