This commit is contained in:
2026-04-27 15:07:30 +02:00
commit 7757def083
21 changed files with 2791 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# HACS-Pakete
Dieser Ordner enthält zwei getrennte, für HACS vorbereitete Paketordner:
- `sternwarte-homeassistant-integration`
- `sternwarte-homeassistant-card`
## Gedachter Einsatz
Für HACS werden Integration und Card als zwei getrennte Repositories geführt:
1. `sternwarte-homeassistant-integration`
Enthält die Home-Assistant-Integration unter `custom_components/sternwarte/`
2. `sternwarte-homeassistant-card`
Enthält die Lovelace-Card unter `dist/sternwarte-card.js`
## Nächster Schritt
Wenn ihr das später wirklich über HACS verwenden wollt, legt ihr für beide Ordner jeweils ein eigenes Git-Repository an oder kopiert den jeweiligen Inhalt in zwei getrennte GitHub-Repositories.
## Hinweis
Die Integration läuft bereits lokal. Für eine spätere Veröffentlichung sollten in `manifest.json` noch echte `documentation`- und `issue_tracker`-Links auf das spätere Repository gesetzt werden.
+20
View File
@@ -0,0 +1,20 @@
# Sternwarte Home Assistant Card
Lovelace-Card für die Sternwarte.
## Struktur
```text
dist/
sternwarte-card.js
```
## Für HACS
Dieser Ordner ist als Root eines späteren HACS-Dashboard-Repositorys vorbereitet.
Empfohlener Repository-Name:
- `sternwarte-homeassistant-card`
Nach dem Einbinden über HACS muss in Home Assistant noch wie üblich die Dashboard-Ressource für die Card vorhanden sein, falls HACS sie nicht bereits automatisch registriert.
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
{
"name": "Sternwarte Card",
"render_readme": true
}
@@ -0,0 +1,29 @@
# Sternwarte Home Assistant Integration
Home-Assistant-Custom-Integration für die Sternwarte.
## Struktur
```text
custom_components/
sternwarte/
```
## Für HACS
Dieser Ordner ist als Root eines späteren HACS-Repositorys vorbereitet.
Empfohlener Repository-Name:
- `sternwarte-homeassistant-integration`
## Vor einer Veröffentlichung
Vor dem echten Einsatz als HACS-Repository sollten in
`custom_components/sternwarte/manifest.json`
noch passende Werte für
- `documentation`
- `issue_tracker`
auf das spätere Repository gesetzt werden.
@@ -0,0 +1,111 @@
"""Sternwarte Home Assistant integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from functools import partial
from homeassistant.core import HomeAssistant, ServiceCall
from .const import (
DOMAIN,
SERVICE_CLEAR_SCHEDULE,
SERVICE_CLOSE_ROOF,
SERVICE_OPEN_ROOF,
SERVICE_POWER_OFF,
SERVICE_POWER_ON,
SERVICE_SET_NAUTICAL_SCHEDULE,
SERVICE_STOP_ROOF,
)
from .coordinator import SternwarteDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.COVER,
Platform.DATETIME,
Platform.SENSOR,
Platform.SWITCH,
]
async def _async_call_on_all(
hass: HomeAssistant,
entry_action: str,
) -> None:
"""Run an action on all Sternwarte coordinators."""
for coordinator in hass.data.get(DOMAIN, {}).values():
await getattr(coordinator, entry_action)()
async def _async_handle_open_roof(hass: HomeAssistant, call: ServiceCall) -> None:
await _async_call_on_all(hass, "async_open_roof")
async def _async_handle_close_roof(hass: HomeAssistant, call: ServiceCall) -> None:
await _async_call_on_all(hass, "async_close_roof")
async def _async_handle_stop_roof(hass: HomeAssistant, call: ServiceCall) -> None:
await _async_call_on_all(hass, "async_stop_roof")
async def _async_handle_power_on(hass: HomeAssistant, call: ServiceCall) -> None:
await _async_call_on_all(hass, "async_turn_power_on")
async def _async_handle_power_off(hass: HomeAssistant, call: ServiceCall) -> None:
await _async_call_on_all(hass, "async_turn_power_off")
async def _async_handle_clear_schedule(hass: HomeAssistant, call: ServiceCall) -> None:
await _async_call_on_all(hass, "async_clear_schedule")
async def _async_handle_set_nautical_schedule(hass: HomeAssistant, call: ServiceCall) -> None:
await _async_call_on_all(hass, "async_set_next_nautical_schedule")
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Sternwarte from a config entry."""
coordinator = SternwarteDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
if not hass.services.has_service(DOMAIN, SERVICE_OPEN_ROOF):
hass.services.async_register(DOMAIN, SERVICE_OPEN_ROOF, partial(_async_handle_open_roof, hass))
hass.services.async_register(DOMAIN, SERVICE_CLOSE_ROOF, partial(_async_handle_close_roof, hass))
hass.services.async_register(DOMAIN, SERVICE_STOP_ROOF, partial(_async_handle_stop_roof, hass))
hass.services.async_register(DOMAIN, SERVICE_POWER_ON, partial(_async_handle_power_on, hass))
hass.services.async_register(DOMAIN, SERVICE_POWER_OFF, partial(_async_handle_power_off, hass))
hass.services.async_register(DOMAIN, SERVICE_CLEAR_SCHEDULE, partial(_async_handle_clear_schedule, hass))
hass.services.async_register(
DOMAIN,
SERVICE_SET_NAUTICAL_SCHEDULE,
partial(_async_handle_set_nautical_schedule, hass),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id, None)
if not hass.data[DOMAIN]:
for service in (
SERVICE_OPEN_ROOF,
SERVICE_CLOSE_ROOF,
SERVICE_STOP_ROOF,
SERVICE_POWER_ON,
SERVICE_POWER_OFF,
SERVICE_CLEAR_SCHEDULE,
SERVICE_SET_NAUTICAL_SCHEDULE,
):
if hass.services.has_service(DOMAIN, service):
hass.services.async_remove(DOMAIN, service)
return unload_ok
@@ -0,0 +1,109 @@
"""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:
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:
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:
state.counters = [int(parts[counter_index + 1 + i] or "0") for i in range(12)]
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}&")
@@ -0,0 +1,74 @@
"""Binary sensors for Sternwarte."""
from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, ENTITY_RAIN, ENTITY_ROOF_CLOSED, ENTITY_ROOF_MOVING, ENTITY_ROOF_OPEN
from .entity import SternwarteEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Sternwarte binary sensors."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[
SternwarteClosedSensor(coordinator),
SternwarteOpenSensor(coordinator),
SternwarteMovingSensor(coordinator),
SternwarteRainSensor(coordinator),
]
)
class SternwarteClosedSensor(SternwarteEntity, BinarySensorEntity):
_attr_name = "Dach zu"
def __init__(self, coordinator):
super().__init__(coordinator, ENTITY_ROOF_CLOSED)
@property
def is_on(self) -> bool:
return self.coordinator.data.roof_closed
class SternwarteOpenSensor(SternwarteEntity, BinarySensorEntity):
_attr_name = "Dach offen"
def __init__(self, coordinator):
super().__init__(coordinator, ENTITY_ROOF_OPEN)
@property
def is_on(self) -> bool:
return self.coordinator.data.roof_open
class SternwarteMovingSensor(SternwarteEntity, BinarySensorEntity):
_attr_name = "Dach bewegt"
_attr_device_class = BinarySensorDeviceClass.MOVING
def __init__(self, coordinator):
super().__init__(coordinator, ENTITY_ROOF_MOVING)
@property
def is_on(self) -> bool:
return self.coordinator.data.roof_moving
class SternwarteRainSensor(SternwarteEntity, BinarySensorEntity):
_attr_name = "Regen"
_attr_device_class = BinarySensorDeviceClass.MOISTURE
def __init__(self, coordinator):
super().__init__(coordinator, ENTITY_RAIN)
@property
def is_on(self) -> bool:
return self.coordinator.data.raining
@@ -0,0 +1,34 @@
"""Button entities for Sternwarte."""
from __future__ import annotations
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, ENTITY_SET_NAUTICAL_SCHEDULE
from .entity import SternwarteEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Sternwarte buttons."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([SternwarteSetNauticalScheduleButton(coordinator)])
class SternwarteSetNauticalScheduleButton(SternwarteEntity, ButtonEntity):
"""Button to set the next nautical dusk/dawn schedule."""
_attr_name = "Nautische Dämmerung setzen"
_attr_icon = "mdi:weather-night"
def __init__(self, coordinator):
super().__init__(coordinator, ENTITY_SET_NAUTICAL_SCHEDULE)
async def async_press(self) -> None:
await self.coordinator.async_set_next_nautical_schedule()
@@ -0,0 +1,33 @@
"""Config flow for Sternwarte."""
from __future__ import annotations
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.data_entry_flow import FlowResult
from .const import CONF_HOST, CONF_PASSWORD, CONF_PORT, DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PASSWORD, DEFAULT_PORT, DOMAIN
class SternwarteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Sternwarte."""
VERSION = 1
async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
"""Handle the initial step."""
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_HOST])
self._abort_if_unique_id_configured()
return self.async_create_entry(title=DEFAULT_NAME, data=user_input)
schema = vol.Schema(
{
vol.Required(CONF_HOST, default=DEFAULT_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
vol.Required(CONF_PASSWORD, default=DEFAULT_PASSWORD): str,
}
)
return self.async_show_form(step_id="user", data_schema=schema)
@@ -0,0 +1,48 @@
"""Constants for Sternwarte."""
from __future__ import annotations
from typing import Final
DOMAIN = "sternwarte"
CONF_HOST = "host"
CONF_PORT = "port"
CONF_PASSWORD = "password"
DEFAULT_NAME = "Sternwarte"
DEFAULT_HOST = "192.168.1.110"
DEFAULT_PORT = 80
DEFAULT_PASSWORD = "sowo2009"
UPDATE_INTERVAL_SECONDS = 1
POWER_ON_DELAY_SECONDS = 30
WIND_INTERVAL_SECONDS = 30
WIND_COUNTER_INDEX = 4
WIND_FACTOR = (2 / 3) * 3.6
ROOF_POSITION_MAX = 976
ROOF_COUNTER_CLOSE_INDEX = 0
ROOF_COUNTER_OPEN_INDEX = 1
STORAGE_VERSION = 1
STORAGE_KEY = f"{DOMAIN}_schedule"
SERVICE_OPEN_ROOF = "open_roof"
SERVICE_CLOSE_ROOF = "close_roof"
SERVICE_STOP_ROOF = "stop_roof"
SERVICE_POWER_ON = "power_on"
SERVICE_POWER_OFF = "power_off"
SERVICE_CLEAR_SCHEDULE = "clear_schedule"
SERVICE_SET_NAUTICAL_SCHEDULE = "set_nautical_schedule"
ENTITY_ROOF_COVER: Final = "roof"
ENTITY_ROOF_POWER: Final = "roof_power"
ENTITY_ROOF_CLOSED: Final = "roof_closed"
ENTITY_ROOF_OPEN: Final = "roof_open"
ENTITY_ROOF_MOVING: Final = "roof_moving"
ENTITY_ROOF_STATUS: Final = "roof_status"
ENTITY_RAIN: Final = "rain"
ENTITY_WIND_SPEED: Final = "wind_speed"
ENTITY_ROOF_POSITION: Final = "roof_position"
ENTITY_SCHEDULE_OPEN: Final = "schedule_open"
ENTITY_SCHEDULE_CLOSE: Final = "schedule_close"
ENTITY_SET_NAUTICAL_SCHEDULE: Final = "set_nautical_schedule"
@@ -0,0 +1,829 @@
"""Coordinator for Sternwarte state updates."""
from __future__ import annotations
import asyncio
from collections import deque
from dataclasses import asdict
from datetime import UTC, datetime, timedelta
import logging
from math import acos, asin, atan2, cos, floor, pi, sin
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from .api import SternwarteApi
from .const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
DEFAULT_NAME,
POWER_ON_DELAY_SECONDS,
ROOF_COUNTER_CLOSE_INDEX,
ROOF_COUNTER_OPEN_INDEX,
ROOF_POSITION_MAX,
STORAGE_KEY,
STORAGE_VERSION,
UPDATE_INTERVAL_SECONDS,
WIND_COUNTER_INDEX,
WIND_FACTOR,
WIND_INTERVAL_SECONDS,
)
from .models import SternwarteSchedule, SternwarteState, WindSample
LOGGER = logging.getLogger(__name__)
class SternwarteDataUpdateCoordinator(DataUpdateCoordinator[SternwarteState]):
"""Handle fetching Sternwarte state and runtime logic."""
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.config_entry = entry
self.api = SternwarteApi(
host=entry.data[CONF_HOST],
port=entry.data[CONF_PORT],
password=entry.data[CONF_PASSWORD],
)
super().__init__(
hass,
logger=LOGGER,
name=DEFAULT_NAME,
update_interval=timedelta(seconds=UPDATE_INTERVAL_SECONDS),
)
self._command_lock = asyncio.Lock()
self._store: Store[dict[str, Any]] = Store(
hass,
STORAGE_VERSION,
f"{STORAGE_KEY}_{entry.entry_id}",
)
self.schedule = SternwarteSchedule()
self._wind_last_counter: int | None = None
self._wind_last_time: datetime | None = None
self._wind_next_measure_at: datetime | None = None
self._wind_history: deque[WindSample] = deque(maxlen=120)
self._pending_open_task: asyncio.Task | None = None
self._pending_close_task: asyncio.Task | None = None
self._roof_was_closed = False
async def async_config_entry_first_refresh(self) -> None:
"""Load persisted runtime state before first refresh."""
await self._async_load_runtime_state()
await super().async_config_entry_first_refresh()
async def _async_load_runtime_state(self) -> None:
stored = await self._store.async_load()
if not stored:
return
self.schedule = SternwarteSchedule(
open_at=stored.get("open_at", ""),
close_at=stored.get("close_at", ""),
open_done=stored.get("open_done", False),
close_done=stored.get("close_done", False),
open_power_on_at=stored.get("open_power_on_at", ""),
close_power_on_at=stored.get("close_power_on_at", ""),
rain_close_power_on_at=stored.get("rain_close_power_on_at", ""),
close_power_off_at=stored.get("close_power_off_at", ""),
pending_auto_close=stored.get("pending_auto_close", False),
)
self._wind_last_counter = stored.get("wind_last_counter")
self._wind_last_time = self._parse_datetime(stored.get("wind_last_time", ""))
self._wind_next_measure_at = self._parse_datetime(stored.get("wind_next_measure_at", ""))
for item in stored.get("wind_history", []):
item_time = self._parse_datetime(item.get("time", ""))
if item_time is not None:
self._wind_history.append(
WindSample(time=item_time, kmh=float(item.get("kmh", 0.0)))
)
async def _async_save_runtime_state(self) -> None:
payload = asdict(self.schedule)
payload["wind_last_counter"] = self._wind_last_counter
payload["wind_last_time"] = self._wind_last_time.isoformat() if self._wind_last_time else ""
payload["wind_next_measure_at"] = (
self._wind_next_measure_at.isoformat() if self._wind_next_measure_at else ""
)
payload["wind_history"] = [
{"time": sample.time.isoformat(), "kmh": sample.kmh} for sample in self._wind_history
]
await self._store.async_save(payload)
async def _async_update_data(self) -> SternwarteState:
"""Fetch state from the device and process runtime logic."""
state = await self.api.fetch_state()
state = await self._async_process_position_state(state)
now = dt_util.utcnow()
self._update_wind_state(state, now)
state = await self._async_process_schedule(state, now)
return state
async def _async_safe_refresh(self) -> None:
try:
await self.async_request_refresh()
except Exception:
LOGGER.exception("Status-Refresh nach Web-IO-Befehl fehlgeschlagen")
def _parse_datetime(self, value: str) -> datetime | None:
if not value:
return None
parsed = dt_util.parse_datetime(value)
if parsed is None:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)
return parsed.astimezone(UTC)
def _format_schedule_datetime(self, value: datetime | None, *, truncate_seconds: bool = True) -> str:
if value is None:
return ""
local = dt_util.as_local(value)
if truncate_seconds:
local = local.replace(second=0, microsecond=0)
else:
local = local.replace(microsecond=0)
return local.isoformat()
def _schedule_datetime(self, value: str) -> datetime | None:
return self._parse_datetime(value)
def _normalize_user_schedule_datetime(self, value: datetime | None) -> datetime | None:
if value is None:
return None
return dt_util.as_local(value).replace(second=0, microsecond=0)
@property
def roof_position_percent(self) -> int:
position = max(
0,
self.data.counters[ROOF_COUNTER_OPEN_INDEX] - self.data.counters[ROOF_COUNTER_CLOSE_INDEX],
)
return min(100, round(position / ROOF_POSITION_MAX * 100))
@property
def open_schedule_at(self) -> datetime | None:
return self._schedule_datetime(self.schedule.open_at)
@property
def close_schedule_at(self) -> datetime | None:
return self._schedule_datetime(self.schedule.close_at)
async def async_set_open_schedule(self, when: datetime | None) -> None:
"""Persist open schedule."""
when = self._normalize_user_schedule_datetime(when)
self.schedule.open_at = self._format_schedule_datetime(when)
self.schedule.open_done = when is None
self.schedule.open_power_on_at = ""
if when is not None:
close_at = self._normalize_user_schedule_datetime(self.close_schedule_at)
if close_at is not None and close_at < when + timedelta(minutes=10):
self.schedule.close_at = ""
self.schedule.close_done = False
self.schedule.close_power_on_at = ""
self.schedule.close_power_off_at = ""
self.schedule.pending_auto_close = False
await self._async_save_runtime_state()
await self.async_request_refresh()
async def async_clear_schedule(self) -> None:
"""Clear all schedule-related runtime state."""
self.schedule = SternwarteSchedule()
await self._async_save_runtime_state()
await self.async_request_refresh()
async def async_set_close_schedule(self, when: datetime | None) -> None:
"""Persist close schedule."""
when = self._normalize_user_schedule_datetime(when)
open_at = self._normalize_user_schedule_datetime(self._schedule_datetime(self.schedule.open_at))
if when is not None and open_at is not None and when < open_at + timedelta(minutes=10):
minimum_close_at = open_at + timedelta(minutes=10)
LOGGER.warning(
"Schließzeitpunkt angepasst: open_at=%s requested_close_at=%s adjusted_close_at=%s",
open_at.isoformat(),
when.isoformat(),
minimum_close_at.isoformat(),
)
when = minimum_close_at
self.schedule.close_at = self._format_schedule_datetime(when)
self.schedule.close_done = when is None
self.schedule.close_power_on_at = ""
self.schedule.close_power_off_at = ""
self.schedule.pending_auto_close = False
await self._async_save_runtime_state()
await self.async_request_refresh()
async def async_set_next_nautical_schedule(self) -> None:
"""Set the next nautical dusk/dawn schedule."""
now = dt_util.now()
next_schedule = self._get_next_nautical_schedule(now)
if next_schedule is None:
raise HomeAssistantError("Keine kommende nautische Dämmerung gefunden")
self.schedule.open_at = self._format_schedule_datetime(next_schedule["open_at"])
self.schedule.close_at = self._format_schedule_datetime(next_schedule["close_at"])
self.schedule.open_done = False
self.schedule.close_done = False
self.schedule.open_power_on_at = ""
self.schedule.close_power_on_at = ""
self.schedule.rain_close_power_on_at = ""
self.schedule.close_power_off_at = ""
self.schedule.pending_auto_close = False
await self._async_save_runtime_state()
await self.async_request_refresh()
async def async_turn_power_on(self) -> None:
"""Turn roof power on."""
async with self._command_lock:
await self.api.set_output(0, True)
await self._async_safe_refresh()
async def async_turn_power_off(self) -> None:
"""Turn roof power off."""
async with self._command_lock:
self.schedule.open_power_on_at = ""
self.schedule.close_power_on_at = ""
self.schedule.rain_close_power_on_at = ""
self.schedule.close_power_off_at = ""
self.schedule.pending_auto_close = False
await self.api.set_output(0, False)
await self._async_save_runtime_state()
await self._async_safe_refresh()
async def async_stop_roof(self) -> None:
"""Stop roof motion."""
async with self._command_lock:
self._cancel_pending_open_task()
self._cancel_pending_close_task()
await self.api.set_output(10, True)
await self.api.set_output(9, False)
await self.api.set_output(11, False)
await self._async_safe_refresh()
async def async_open_roof(self) -> None:
"""Open the roof with required power-on sequence."""
async with self._command_lock:
if self._is_roof_open(self.data):
return
if self.data.raining:
raise HomeAssistantError("Regen erkannt, Dach kann nicht geöffnet werden")
if self.data.roof_power:
self._cancel_pending_close_task()
self._cancel_pending_open_task()
await self._async_open_roof_action(self.data)
else:
LOGGER.info("Manuelles Öffnen: Dachsteuerung wird eingeschaltet, Öffnen folgt in 30 Sekunden")
await self.api.set_output(0, True)
self._schedule_pending_open_task(POWER_ON_DELAY_SECONDS)
await self._async_safe_refresh()
async def async_close_roof(self) -> None:
"""Close the roof."""
async with self._command_lock:
if self._is_roof_closed(self.data):
return
self._cancel_pending_open_task()
self._cancel_pending_close_task()
if self.data.roof_power:
self._cancel_pending_close_task()
await self._async_close_roof_action(self.data)
else:
LOGGER.info("Manuelles Schließen: Dachsteuerung wird eingeschaltet, Schließen folgt in 30 Sekunden")
await self.api.set_output(0, True)
self._schedule_pending_close_task(POWER_ON_DELAY_SECONDS)
await self._async_safe_refresh()
async def _async_open_roof_action(self, state: SternwarteState) -> None:
LOGGER.info(
"Öffnungsaktion gestartet: power=%s moving=%s open=%s closed=%s",
state.roof_power,
state.roof_moving,
state.roof_open,
state.roof_closed,
)
if state.roof_open and not state.roof_closed:
return
if state.raining:
raise HomeAssistantError("Regen erkannt, Dach kann nicht geöffnet werden")
if not state.roof_power:
raise HomeAssistantError("Dachsteuerung ist ausgeschaltet")
if state.roof_moving:
raise HomeAssistantError("Dach bewegt sich bereits")
await self.api.set_output(9, True)
await self.api.set_output(10, False)
await self.api.set_output(11, False)
async def _async_close_roof_action(self, state: SternwarteState) -> None:
if not state.roof_power:
raise HomeAssistantError("Dachsteuerung ist ausgeschaltet")
await self.api.set_output(11, True)
await self.api.set_output(10, False)
await self.api.set_output(9, False)
def _can_auto_open(self, state: SternwarteState) -> bool:
return not state.roof_moving and not state.roof_open and not state.raining
def _can_auto_close(self, state: SternwarteState) -> bool:
return state.roof_power
def _is_roof_open(self, state: SternwarteState) -> bool:
return state.roof_open and not state.roof_closed
def _is_roof_closed(self, state: SternwarteState) -> bool:
return state.roof_closed and not state.roof_open
def _cancel_pending_open_task(self) -> None:
if self._pending_open_task is not None and not self._pending_open_task.done():
self._pending_open_task.cancel()
self._pending_open_task = None
def _schedule_pending_open_task(self, delay_seconds: int) -> None:
self._cancel_pending_open_task()
self._pending_open_task = self.hass.async_create_task(
self._async_finish_pending_open(delay_seconds)
)
async def _async_finish_pending_open(self, delay_seconds: int) -> None:
try:
await asyncio.sleep(delay_seconds)
should_refresh = False
async with self._command_lock:
state = await self.api.fetch_state()
self._update_wind_state(state, dt_util.utcnow())
LOGGER.info(
"Verzögertes Öffnen nach Einschalten: power=%s moving=%s open=%s closed=%s",
state.roof_power,
state.roof_moving,
state.roof_open,
state.roof_closed,
)
if self._is_roof_open(state):
return
if state.raining:
LOGGER.warning("Verzögertes Öffnen abgebrochen: Regen erkannt")
return
await self._async_open_roof_action(state)
should_refresh = True
if should_refresh:
await self._async_safe_refresh()
except asyncio.CancelledError:
LOGGER.info("Verzögertes Öffnen wurde abgebrochen")
raise
except Exception:
LOGGER.exception("Verzögertes Öffnen nach Einschalten fehlgeschlagen")
finally:
self._pending_open_task = None
def _cancel_pending_close_task(self) -> None:
if self._pending_close_task is not None and not self._pending_close_task.done():
self._pending_close_task.cancel()
self._pending_close_task = None
def _schedule_pending_close_task(self, delay_seconds: int) -> None:
self._cancel_pending_close_task()
self._pending_close_task = self.hass.async_create_task(
self._async_finish_pending_close(delay_seconds)
)
async def _async_finish_pending_close(self, delay_seconds: int) -> None:
try:
await asyncio.sleep(delay_seconds)
should_refresh = False
async with self._command_lock:
state = await self.api.fetch_state()
self._update_wind_state(state, dt_util.utcnow())
LOGGER.info(
"Verzögertes Schließen nach Einschalten: power=%s moving=%s open=%s closed=%s",
state.roof_power,
state.roof_moving,
state.roof_open,
state.roof_closed,
)
if self._is_roof_closed(state):
return
await self._async_close_roof_action(state)
should_refresh = True
if should_refresh:
await self._async_safe_refresh()
except asyncio.CancelledError:
LOGGER.info("Verzögertes Schließen wurde abgebrochen")
raise
except Exception:
LOGGER.exception("Verzögertes Schließen nach Einschalten fehlgeschlagen")
finally:
self._pending_close_task = None
async def _async_process_position_state(self, state: SternwarteState) -> SternwarteState:
if state.roof_closed and not self._roof_was_closed:
LOGGER.info("Dach geschlossen erkannt, Positionszähler werden zurückgesetzt")
await self.api.clear_counter(ROOF_COUNTER_CLOSE_INDEX)
await self.api.clear_counter(ROOF_COUNTER_OPEN_INDEX)
state.counters[ROOF_COUNTER_CLOSE_INDEX] = 0
state.counters[ROOF_COUNTER_OPEN_INDEX] = 0
self._roof_was_closed = state.roof_closed
return state
async def _async_process_schedule(
self,
state: SternwarteState,
now: datetime,
) -> SternwarteState:
changed = False
must_refetch = False
async with self._command_lock:
rain_close_power_on_at = self._schedule_datetime(self.schedule.rain_close_power_on_at)
rain_close_pending = state.raining or rain_close_power_on_at is not None
if rain_close_pending:
self._cancel_pending_open_task()
if self.schedule.open_power_on_at:
self.schedule.open_power_on_at = ""
changed = True
if self._is_roof_closed(state):
if self.schedule.rain_close_power_on_at:
LOGGER.info("Regenschließen erledigt, Dach ist bereits geschlossen")
self.schedule.rain_close_power_on_at = ""
changed = True
elif rain_close_power_on_at is not None and now >= rain_close_power_on_at:
if not state.roof_power:
LOGGER.warning(
"Regenschließen wartet weiter: Dachsteuerung nach Einschaltverzögerung noch aus"
)
await self.api.set_output(0, True)
state.outputs[0] = True
self.schedule.rain_close_power_on_at = self._format_schedule_datetime(
dt_util.as_local(now) + timedelta(seconds=POWER_ON_DELAY_SECONDS),
truncate_seconds=False,
)
must_refetch = True
changed = True
elif self._can_auto_close(state):
LOGGER.warning("Regen erkannt, Dach wird nach Einschaltverzögerung geschlossen")
await self._async_close_roof_action(state)
self.schedule.rain_close_power_on_at = ""
self.schedule.pending_auto_close = True
self.schedule.close_power_off_at = ""
must_refetch = True
changed = True
elif rain_close_power_on_at is None:
if not state.roof_power:
LOGGER.warning("Regen erkannt, Dachsteuerung wird eingeschaltet, Schließen folgt in 30 Sekunden")
await self.api.set_output(0, True)
state.outputs[0] = True
self.schedule.rain_close_power_on_at = self._format_schedule_datetime(
dt_util.as_local(now) + timedelta(seconds=POWER_ON_DELAY_SECONDS),
truncate_seconds=False,
)
must_refetch = True
changed = True
elif self._can_auto_close(state):
LOGGER.warning("Regen erkannt, Dach wird sofort geschlossen")
await self._async_close_roof_action(state)
self.schedule.pending_auto_close = True
self.schedule.close_power_off_at = ""
must_refetch = True
changed = True
if self.schedule.pending_auto_close and state.roof_closed and not self.schedule.close_power_off_at:
self.schedule.close_power_off_at = self._format_schedule_datetime(
dt_util.as_local(now) + timedelta(seconds=POWER_ON_DELAY_SECONDS),
truncate_seconds=False,
)
changed = True
close_power_off_at = self._schedule_datetime(self.schedule.close_power_off_at)
if close_power_off_at is not None and now >= close_power_off_at:
if state.roof_power:
await self.api.set_output(0, False)
state.outputs[0] = False
must_refetch = True
self.schedule.close_power_off_at = ""
self.schedule.pending_auto_close = False
changed = True
close_power_on_at = self._schedule_datetime(self.schedule.close_power_on_at)
if close_power_on_at is not None and not self.schedule.close_done and now >= close_power_on_at:
if self._is_roof_closed(state):
LOGGER.info("Automatisches Schließen bereits erfüllt")
self.schedule.close_done = True
self.schedule.close_power_on_at = ""
self.schedule.pending_auto_close = False
self.schedule.close_power_off_at = ""
changed = True
elif not state.roof_power:
LOGGER.warning(
"Automatisches Schließen wartet weiter: Dachsteuerung nach Einschaltverzögerung noch aus"
)
await self.api.set_output(0, True)
state.outputs[0] = True
self.schedule.close_power_on_at = self._format_schedule_datetime(
dt_util.as_local(now) + timedelta(seconds=POWER_ON_DELAY_SECONDS),
truncate_seconds=False,
)
must_refetch = True
changed = True
elif self._can_auto_close(state):
LOGGER.info("Automatisches Schließen wird nach Einschaltverzögerung ausgeführt")
await self._async_close_roof_action(state)
self.schedule.close_done = True
self.schedule.close_power_on_at = ""
self.schedule.pending_auto_close = True
self.schedule.close_power_off_at = ""
must_refetch = True
changed = True
else:
LOGGER.warning(
"Automatisches Schließen noch blockiert: power=%s moving=%s open=%s closed=%s",
state.roof_power,
state.roof_moving,
state.roof_open,
state.roof_closed,
)
refetched_state = await self.api.fetch_state()
self._update_wind_state(refetched_state, now)
if refetched_state.roof_power and self._can_auto_close(refetched_state):
LOGGER.info("Automatisches Schließen wird nach Zusatzprüfung ausgeführt")
await self._async_close_roof_action(refetched_state)
self.schedule.close_done = True
self.schedule.close_power_on_at = ""
self.schedule.pending_auto_close = True
self.schedule.close_power_off_at = ""
state = refetched_state
must_refetch = True
changed = True
else:
LOGGER.warning(
"Automatisches Schließen weiter blockiert nach Zusatzprüfung: power=%s moving=%s open=%s closed=%s",
refetched_state.roof_power,
refetched_state.roof_moving,
refetched_state.roof_open,
refetched_state.roof_closed,
)
self.schedule.close_power_on_at = self._format_schedule_datetime(
dt_util.as_local(now) + timedelta(seconds=5),
truncate_seconds=False,
)
changed = True
open_power_on_at = self._schedule_datetime(self.schedule.open_power_on_at)
if open_power_on_at is not None and not self.schedule.open_done and now >= open_power_on_at:
if self._is_roof_open(state):
LOGGER.info("Automatisches Öffnen bereits erfüllt")
self.schedule.open_done = True
self.schedule.open_power_on_at = ""
changed = True
elif not state.roof_power:
LOGGER.warning(
"Automatisches Öffnen wartet weiter: Dachsteuerung nach Einschaltverzögerung noch aus"
)
await self.api.set_output(0, True)
state.outputs[0] = True
self.schedule.open_power_on_at = self._format_schedule_datetime(
dt_util.as_local(now) + timedelta(seconds=POWER_ON_DELAY_SECONDS),
truncate_seconds=False,
)
must_refetch = True
changed = True
elif self._can_auto_open(state):
LOGGER.info("Automatisches Öffnen wird nach Einschaltverzögerung ausgeführt")
await self._async_open_roof_action(state)
self.schedule.open_done = True
self.schedule.open_power_on_at = ""
must_refetch = True
changed = True
elif state.raining:
LOGGER.warning("Automatisches Öffnen blockiert: Regen erkannt")
else:
LOGGER.warning(
"Automatisches Öffnen noch blockiert: power=%s moving=%s open=%s closed=%s",
state.roof_power,
state.roof_moving,
state.roof_open,
state.roof_closed,
)
refetched_state = await self.api.fetch_state()
self._update_wind_state(refetched_state, now)
if refetched_state.roof_power and self._can_auto_open(refetched_state):
LOGGER.info("Automatisches Öffnen wird nach Zusatzprüfung ausgeführt")
await self._async_open_roof_action(refetched_state)
self.schedule.open_done = True
self.schedule.open_power_on_at = ""
state = refetched_state
must_refetch = True
changed = True
else:
LOGGER.warning(
"Automatisches Öffnen weiter blockiert nach Zusatzprüfung: power=%s moving=%s open=%s closed=%s",
refetched_state.roof_power,
refetched_state.roof_moving,
refetched_state.roof_open,
refetched_state.roof_closed,
)
self.schedule.open_power_on_at = self._format_schedule_datetime(
dt_util.as_local(now) + timedelta(seconds=5),
truncate_seconds=False,
)
changed = True
open_at = self._schedule_datetime(self.schedule.open_at)
if open_at is not None and not self.schedule.open_done and now >= open_at and not self.schedule.open_power_on_at:
if self._is_roof_open(state):
LOGGER.info("Automatisches Öffnen übersprungen, Dach ist bereits offen")
self.schedule.open_done = True
changed = True
elif state.roof_power and self._can_auto_open(state):
LOGGER.info("Automatisches Öffnen wird ausgeführt")
await self._async_open_roof_action(state)
self.schedule.open_done = True
must_refetch = True
changed = True
elif state.raining:
LOGGER.warning("Automatisches Öffnen blockiert: Regen erkannt")
elif not state.roof_power:
LOGGER.info("Automatisches Öffnen schaltet zuerst die Dachsteuerung ein")
await self.api.set_output(0, True)
state.outputs[0] = True
self.schedule.open_power_on_at = self._format_schedule_datetime(
dt_util.as_local(now) + timedelta(seconds=POWER_ON_DELAY_SECONDS),
truncate_seconds=False,
)
must_refetch = True
changed = True
else:
LOGGER.info(
"Automatisches Öffnen verschoben: moving=%s open=%s closed=%s",
state.roof_moving,
state.roof_open,
state.roof_closed,
)
close_at = self._schedule_datetime(self.schedule.close_at)
if (
close_at is not None
and not self.schedule.close_done
and now >= close_at
and not self.schedule.close_power_on_at
):
if self._is_roof_closed(state):
LOGGER.info("Automatisches Schließen übersprungen, Dach ist bereits geschlossen")
self.schedule.close_done = True
self.schedule.close_power_on_at = ""
self.schedule.pending_auto_close = False
self.schedule.close_power_off_at = ""
changed = True
elif self._can_auto_close(state):
LOGGER.info("Automatisches Schließen wird ausgeführt")
await self._async_close_roof_action(state)
self.schedule.close_done = True
self.schedule.close_power_on_at = ""
self.schedule.pending_auto_close = True
self.schedule.close_power_off_at = ""
must_refetch = True
changed = True
elif not state.roof_power:
LOGGER.info("Automatisches Schließen schaltet zuerst die Dachsteuerung ein")
await self.api.set_output(0, True)
state.outputs[0] = True
self.schedule.close_power_on_at = self._format_schedule_datetime(
dt_util.as_local(now) + timedelta(seconds=POWER_ON_DELAY_SECONDS),
truncate_seconds=False,
)
must_refetch = True
changed = True
else:
LOGGER.warning(
"Automatisches Schließen konnte nicht ausgeführt werden: power=%s moving=%s open=%s closed=%s",
state.roof_power,
state.roof_moving,
state.roof_open,
state.roof_closed,
)
if changed:
await self._async_save_runtime_state()
if must_refetch:
state = await self.api.fetch_state()
self._update_wind_state(state, now)
return state
def _update_wind_state(self, state: SternwarteState, now: datetime) -> None:
counter = state.counters[WIND_COUNTER_INDEX]
if self._wind_next_measure_at is None:
self._wind_next_measure_at = now + timedelta(seconds=WIND_INTERVAL_SECONDS)
self._wind_last_counter = counter
self._wind_last_time = now
if (
self._wind_last_counter is not None
and self._wind_last_time is not None
and self._wind_next_measure_at is not None
and now >= self._wind_next_measure_at
):
delta = counter - self._wind_last_counter
elapsed = max((now - self._wind_last_time).total_seconds(), 0.001)
if delta >= 0:
kmh = (delta / elapsed) * WIND_FACTOR
self._wind_history.append(WindSample(time=now, kmh=round(kmh, 1)))
self._wind_last_counter = counter
self._wind_last_time = now
self._wind_next_measure_at = now + timedelta(seconds=WIND_INTERVAL_SECONDS)
if self._wind_history:
values = [sample.kmh for sample in self._wind_history]
state.wind_speed_kmh = round(sum(values[-3:]) / min(len(values), 3), 1)
def _calc_twilight(self, date: datetime) -> dict[str, dict[str, float] | None]:
jd = date.timestamp() / 86400 + 2440587.5
n = jd - 2451545.0
l = (280.460 + 0.9856474 * n) % 360
g = (357.528 + 0.9856003 * n) % 360
g_r = g * pi / 180
lam = l + 1.915 * sin(g_r) + 0.020 * sin(2 * g_r)
lam_r = lam * pi / 180
eps = 23.439 - 0.0000004 * n
eps_r = eps * pi / 180
dec = asin(sin(eps_r) * sin(lam_r))
ra = atan2(cos(eps_r) * sin(lam_r), cos(lam_r)) * 180 / pi / 15
gmst0h = ((l + 180) % 360) / 15
solar_noon = (12 - self.hass.config.longitude / 15 - ((ra - gmst0h + 12 + 720) % 24) + 24) % 24
lat_r = self.hass.config.latitude * pi / 180
def event_utc(dep_deg: float) -> dict[str, float] | None:
cos_h = (
sin(-dep_deg * pi / 180) - sin(lat_r) * sin(dec)
) / (cos(lat_r) * cos(dec))
if cos_h < -1 or cos_h > 1:
return None
h = acos(cos_h) * 180 / pi / 15
return {"dawn": (solar_noon - h + 24) % 24, "dusk": (solar_noon + h) % 24}
return {
"sun": event_utc(0.833),
"civil": event_utc(6),
"naut": event_utc(12),
"astro": event_utc(18),
}
def _event_to_local_datetime(self, base_date: datetime, utc_hour: float | None) -> datetime | None:
if utc_hour is None:
return None
hour = floor(utc_hour)
minute = floor((utc_hour - hour) * 60)
second = floor((((utc_hour - hour) * 60) - minute) * 60)
event_utc = datetime(
base_date.year,
base_date.month,
base_date.day,
hour,
minute,
second,
tzinfo=UTC,
)
return dt_util.as_local(event_utc).replace(second=0, microsecond=0)
def _get_next_nautical_schedule(self, now: datetime) -> dict[str, datetime] | None:
for day_offset in range(3):
base_date = now + timedelta(days=day_offset)
twilight = self._calc_twilight(base_date)
if twilight["naut"] is None:
continue
dusk = self._event_to_local_datetime(base_date, twilight["naut"]["dusk"])
dawn = self._event_to_local_datetime(base_date, twilight["naut"]["dawn"])
if dusk and dusk > now:
close_date = None
for close_offset in range(day_offset, day_offset + 3):
close_base = now + timedelta(days=close_offset)
close_twilight = self._calc_twilight(close_base)
if close_twilight["naut"] is None:
continue
candidate = self._event_to_local_datetime(close_base, close_twilight["naut"]["dawn"])
if candidate and candidate > dusk:
close_date = candidate
break
if close_date is not None:
return {"open_at": dusk, "close_at": close_date}
if day_offset == 0 and dawn and dawn > now:
return None
return None
@@ -0,0 +1,62 @@
"""Cover platform for Sternwarte."""
from __future__ import annotations
from homeassistant.components.cover import CoverEntity, CoverEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, ENTITY_ROOF_COVER
from .entity import SternwarteEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Sternwarte cover."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([SternwarteRoofCover(coordinator)])
class SternwarteRoofCover(SternwarteEntity, CoverEntity):
"""Representation of the observatory roof."""
_attr_name = "Dach"
_attr_supported_features = (
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
)
def __init__(self, coordinator):
super().__init__(coordinator, ENTITY_ROOF_COVER)
@property
def is_closed(self) -> bool | None:
if self.coordinator.data.roof_closed and not self.coordinator.data.roof_open:
return True
if self.coordinator.data.roof_open and not self.coordinator.data.roof_closed:
return False
return None
@property
def is_opening(self) -> bool:
return self.coordinator.data.roof_moving and not self.coordinator.data.roof_open
@property
def is_closing(self) -> bool:
return self.coordinator.data.roof_moving and not self.coordinator.data.roof_closed
@property
def current_cover_position(self) -> int | None:
return self.coordinator.roof_position_percent
async def async_open_cover(self, **kwargs) -> None:
await self.coordinator.async_open_roof()
async def async_close_cover(self, **kwargs) -> None:
await self.coordinator.async_close_roof()
async def async_stop_cover(self, **kwargs) -> None:
await self.coordinator.async_stop_roof()
@@ -0,0 +1,74 @@
"""Datetime entities for Sternwarte schedules."""
from __future__ import annotations
from datetime import datetime
from homeassistant.components.datetime import DateTimeEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN, ENTITY_SCHEDULE_CLOSE, ENTITY_SCHEDULE_OPEN
from .entity import SternwarteEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Sternwarte datetime entities."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[
SternwarteOpenScheduleEntity(coordinator),
SternwarteCloseScheduleEntity(coordinator),
]
)
class SternwarteScheduleEntity(SternwarteEntity, DateTimeEntity):
"""Base datetime entity for schedules."""
_attr_icon = "mdi:calendar-clock"
async def _async_set_schedule(self, value: datetime | None) -> None:
raise NotImplementedError
async def async_set_value(self, value: datetime) -> None:
if value.tzinfo is None:
value = value.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)
await self._async_set_schedule(value)
class SternwarteOpenScheduleEntity(SternwarteScheduleEntity):
_attr_name = "Öffnen geplant"
def __init__(self, coordinator):
super().__init__(coordinator, ENTITY_SCHEDULE_OPEN)
@property
def native_value(self) -> datetime | None:
value = self.coordinator.open_schedule_at
return dt_util.as_local(value) if value is not None else None
async def _async_set_schedule(self, value: datetime | None) -> None:
await self.coordinator.async_set_open_schedule(value)
class SternwarteCloseScheduleEntity(SternwarteScheduleEntity):
_attr_name = "Schließen geplant"
def __init__(self, coordinator):
super().__init__(coordinator, ENTITY_SCHEDULE_CLOSE)
@property
def native_value(self) -> datetime | None:
value = self.coordinator.close_schedule_at
return dt_util.as_local(value) if value is not None else None
async def _async_set_schedule(self, value: datetime | None) -> None:
await self.coordinator.async_set_close_schedule(value)
@@ -0,0 +1,31 @@
"""Base entity helpers for Sternwarte."""
from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import SternwarteDataUpdateCoordinator
from .const import DOMAIN
class SternwarteEntity(CoordinatorEntity[SternwarteDataUpdateCoordinator]):
"""Base Sternwarte entity."""
_attr_has_entity_name = True
def __init__(self, coordinator: SternwarteDataUpdateCoordinator, key: str) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{key}"
@property
def device_info(self) -> DeviceInfo:
"""Return device information."""
host = self.coordinator.config_entry.data["host"]
return DeviceInfo(
identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)},
name="Sternwarte",
manufacturer="W&T",
model="Web-IO 57630",
configuration_url=f"http://{host}",
)
@@ -0,0 +1,11 @@
{
"domain": "sternwarte",
"name": "Sternwarte",
"version": "1.0.0",
"dependencies": [],
"codeowners": [],
"requirements": [],
"iot_class": "local_polling",
"integration_type": "device",
"config_flow": true
}
@@ -0,0 +1,59 @@
"""Shared data models for Sternwarte."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
@dataclass(slots=True)
class SternwarteState:
"""Parsed state from the Web-IO."""
inputs: list[bool] = field(default_factory=lambda: [False] * 12)
outputs: list[bool] = field(default_factory=lambda: [False] * 12)
counters: list[int] = field(default_factory=lambda: [0] * 12)
wind_speed_kmh: float = 0.0
@property
def roof_closed(self) -> bool:
return self.inputs[10]
@property
def roof_open(self) -> bool:
return self.inputs[11]
@property
def roof_moving(self) -> bool:
return self.inputs[9]
@property
def raining(self) -> bool:
return self.inputs[7]
@property
def roof_power(self) -> bool:
return self.outputs[0]
@dataclass(slots=True)
class SternwarteSchedule:
"""Persistent schedule state."""
open_at: str = ""
close_at: str = ""
open_done: bool = False
close_done: bool = False
open_power_on_at: str = ""
close_power_on_at: str = ""
rain_close_power_on_at: str = ""
close_power_off_at: str = ""
pending_auto_close: bool = False
@dataclass(slots=True)
class WindSample:
"""Single wind measurement."""
time: datetime
kmh: float
@@ -0,0 +1,86 @@
"""Sensors for Sternwarte."""
from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfSpeed
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, ENTITY_ROOF_POSITION, ENTITY_ROOF_STATUS, ENTITY_WIND_SPEED
from .entity import SternwarteEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Sternwarte sensors."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[
SternwarteRoofStatusSensor(coordinator),
SternwarteRoofPositionSensor(coordinator),
SternwarteWindSpeedSensor(coordinator),
]
)
class SternwarteWindBaseSensor(SternwarteEntity, SensorEntity):
"""Base wind sensor."""
_attr_device_class = SensorDeviceClass.WIND_SPEED
_attr_native_unit_of_measurement = UnitOfSpeed.KILOMETERS_PER_HOUR
_attr_state_class = SensorStateClass.MEASUREMENT
class SternwarteRoofPositionSensor(SternwarteEntity, SensorEntity):
"""Roof position in percent."""
_attr_name = "Dachposition"
_attr_native_unit_of_measurement = PERCENTAGE
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_icon = "mdi:garage-variant"
def __init__(self, coordinator):
super().__init__(coordinator, ENTITY_ROOF_POSITION)
@property
def native_value(self) -> int:
return self.coordinator.roof_position_percent
class SternwarteRoofStatusSensor(SternwarteEntity, SensorEntity):
"""Roof status as text."""
_attr_name = "Dachstatus"
_attr_icon = "mdi:garage"
def __init__(self, coordinator):
super().__init__(coordinator, ENTITY_ROOF_STATUS)
@property
def native_value(self) -> str:
state = self.coordinator.data
if state.roof_moving:
return "in_bewegung"
if state.roof_open and not state.roof_closed:
return "offen"
if state.roof_closed and not state.roof_open:
return "geschlossen"
if state.roof_open and state.roof_closed:
return "sensorfehler"
return "unbekannt"
class SternwarteWindSpeedSensor(SternwarteWindBaseSensor):
_attr_name = "Windgeschwindigkeit"
def __init__(self, coordinator):
super().__init__(coordinator, ENTITY_WIND_SPEED)
@property
def native_value(self) -> float:
return round(self.coordinator.data.wind_speed_kmh, 1)
@@ -0,0 +1,27 @@
open_roof:
name: Dach öffnen
description: Öffnet das Dach der Sternwarte.
close_roof:
name: Dach schließen
description: Schließt das Dach der Sternwarte.
stop_roof:
name: Dach stoppen
description: Stoppt die Dachbewegung.
power_on:
name: Dachsteuerung einschalten
description: Schaltet die Dachsteuerung ein.
power_off:
name: Dachsteuerung ausschalten
description: Schaltet die Dachsteuerung aus.
clear_schedule:
name: Zeitplanung löschen
description: Löscht die geplanten Öffnen- und Schließen-Termine.
set_nautical_schedule:
name: Nautische Dämmerung setzen
description: Setzt Öffnen und Schließen auf die nächste nautische Abend- und Morgendämmerung.
@@ -0,0 +1,40 @@
"""Switch platform for Sternwarte."""
from __future__ import annotations
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, ENTITY_ROOF_POWER
from .entity import SternwarteEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Sternwarte switches."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([SternwarteRoofPowerSwitch(coordinator)])
class SternwarteRoofPowerSwitch(SternwarteEntity, SwitchEntity):
"""Roof power switch."""
_attr_name = "Dachsteuerung"
def __init__(self, coordinator):
super().__init__(coordinator, ENTITY_ROOF_POWER)
@property
def is_on(self) -> bool:
return self.coordinator.data.roof_power
async def async_turn_on(self, **kwargs) -> None:
await self.coordinator.async_turn_power_on()
async def async_turn_off(self, **kwargs) -> None:
await self.coordinator.async_turn_power_off()
@@ -0,0 +1,4 @@
{
"name": "Sternwarte Integration",
"render_readme": true
}