Start
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user