Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added LK Systems floor heating integration #134188

Draft
wants to merge 1 commit into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,8 @@ build.json @home-assistant/supervisor
/tests/components/litterrobot/ @natekspencer @tkdrob
/homeassistant/components/livisi/ @StefanIacobLivisi @planbnet
/tests/components/livisi/ @StefanIacobLivisi @planbnet
/homeassistant/components/lk_systems/ @simon-bonnedahl
/tests/components/lk_systems/ @simon-bonnedahl
/homeassistant/components/local_calendar/ @allenporter
/tests/components/local_calendar/ @allenporter
/homeassistant/components/local_ip/ @issacg
Expand Down
137 changes: 137 additions & 0 deletions homeassistant/components/lk_systems/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""LK Systems integration for Home Assistant.

This module handles the setup and core functionality of the LK Systems integration.
"""

import logging
import time

import aiohttp

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant

from .const import DOMAIN
from .coordinator import LKSystemDataUpdateCoordinator
from .exceptions import InvalidAuth

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the integration from a config entry."""
api = LKWebServerAPI(entry.data["email"], entry.data["password"])
await api.login()

coordinator = LKSystemDataUpdateCoordinator(hass, api)
await coordinator.async_config_entry_first_refresh()

hass.data[DOMAIN] = {
"coordinator": coordinator,
"api": api,
}

await hass.config_entries.async_forward_entry_setup(entry, "climate")

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
data = hass.data.pop(DOMAIN, None)
if data:
await data["api"].close()
await hass.config_entries.async_unload_platforms(entry, ["climate"])
return True


class LKWebServerAPI:
"""An API client for the LK Systems webserver."""

def __init__(self, email, password) -> None:
"""Initialize the API client."""
self.base_url = "https://my.lk.nu"
self.email = email
self.password = password
self.session = None

async def login(self):
"""Log in to the LK Systems API and initialize the session."""
if self.session is None:
self.session = aiohttp.ClientSession()

login_url = f"{self.base_url}/login"
payload = {"email": self.email, "password": self.password}
async with self.session.post(login_url, data=payload) as response:
response.raise_for_status()
result = await response.json()
if result.get("error") == "1":
_LOGGER.error("Login failed: %s", result.get("msg", "Unknown error"))
raise InvalidAuth(result.get("msg", "Access denied."))

async def get_main_data(self):
"""Fetch the main data from the LK Systems webserver."""
url = f"{self.base_url}/main.json"
if self.session is None:
return {}
async with self.session.get(url) as response:
response.raise_for_status()
return await response.json()

async def set_room_temperature(self, zone_id, temperature) -> dict:
Comment on lines +48 to +81
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

library code (this) needs to be place in a public git repository, together with an OSI approved license, needs to be pushed to PyPi through CICD and installed from there in HA

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay I see. Too much work for me. Hopefully someone can follow this up.

"""Set the target temperature for a specific zone.

:param zone_id: The ID of the zone (tid parameter).
:param temperature: The target temperature in °C (float).
"""
url = f"{self.base_url}/update.cgi"

params = {
"tid": zone_id,
"set_room_deg": int(
temperature * 100
), # Convert temperature to integer format expected by API
"_": int(time.time() * 1000), # Adds a timestamp to avoid caching
}

if self.session is None:
return {}

async with self.session.get(url, params=params) as response:
response.raise_for_status()
try:
result = await response.json()
except aiohttp.ContentTypeError as e:
_LOGGER.error("Failed to parse JSON response: %s", e)
result = None
_LOGGER.info("Set temperature response for zone %s: %s", zone_id, result)
return result

def get_zone_names(self, data) -> list[str]:
"""Decode zone names from raw data."""
raw_names = data.get("name", [])
decoded_names = []
for index, name in enumerate(raw_names):
try:
# Attempt decoding with UTF-8
decoded_name = bytes.fromhex(name).decode("utf-8")
except UnicodeDecodeError:
try:
# Fallback to ISO-8859-1 if UTF-8 decoding fails
decoded_name = bytes.fromhex(name).decode("iso-8859-1")
except UnicodeDecodeError as e:
_LOGGER.error(
"Error decoding name at index %d ('%s'): %s. Using fallback",
index,
name,
e,
)
decoded_name = f"Zone {index + 1}" # Fallback name
decoded_names.append(decoded_name)
return decoded_names

async def close(self):
"""Close the aiohttp session."""
if self.session:
await self.session.close()
self.session = None
84 changes: 84 additions & 0 deletions homeassistant/components/lk_systems/climate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Support for LK Systems climate entities."""

import logging

from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up LK Systems climate entities from a config entry."""
coordinator = hass.data[DOMAIN]["coordinator"]
data = coordinator.data
api = hass.data[DOMAIN]["api"]
zones = api.get_zone_names(data)
entities = [
LKClimateEntity(coordinator, api, zone_id, name)
for zone_id, name in enumerate(zones)
if data["active"][zone_id] == "1"
]
async_add_entities(entities)


class LKClimateEntity(CoordinatorEntity, ClimateEntity):
"""Representation of an LK Systems climate entity."""

def __init__(self, coordinator, api, zone_idx, name):
"""Initialize the climate entity."""
super().__init__(coordinator)
self._api = api
self._zone_idx = zone_idx
self._name = name
self._attr_hvac_mode = HVACMode.HEAT

@property
def name(self):
"""Return the name of the climate entity."""
return self._name

@property
def temperature_unit(self):
"""Return the unit of measurement."""
return UnitOfTemperature.CELSIUS

@property
def supported_features(self):
"""Return the list of supported features."""
return ClimateEntityFeature.TARGET_TEMPERATURE

@property
def hvac_modes(self):
"""Return the list of supported HVAC modes."""
return [HVACMode.HEAT]

@property
def hvac_mode(self):
"""Return the current HVAC mode."""
return self._attr_hvac_mode

@property
def target_temperature(self):
"""Return the target temperature."""
return float(self.coordinator.data["set_room_deg"][self._zone_idx]) / 100.0

@property
def current_temperature(self):
"""Return the current temperature."""
return float(self.coordinator.data["get_room_deg"][self._zone_idx]) / 100.0

async def async_set_temperature(self, **kwargs):
"""Set a new target temperature."""
temperature = kwargs.get("temperature")
if temperature is None:
return
await self._api.set_room_temperature(self._zone_idx, temperature)
await self.coordinator.async_request_refresh()
59 changes: 59 additions & 0 deletions homeassistant/components/lk_systems/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Config flow for the LK Systems integration."""

import logging
from typing import Any

import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD

from . import LKWebServerAPI
from .const import DOMAIN
from .exceptions import CannotConnect, InvalidAuth

_LOGGER = logging.getLogger(__name__)

STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): str,
}
)


async def validate_input(data: dict[str, Any]) -> dict[str, Any]:
"""Validate user input."""
api = LKWebServerAPI(data[CONF_EMAIL], data[CONF_PASSWORD])
try:
await api.login()
except InvalidAuth as err:
raise InvalidAuth from err
finally:
await api.close()
return {"title": "LK Systems"}


class LKSystemsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for LK Systems."""

VERSION = 1

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step."""
errors = {}
if user_input is not None:
try:
info = await validate_input(user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
else:
return self.async_create_entry(title=info["title"], data=user_input)

return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
3 changes: 3 additions & 0 deletions homeassistant/components/lk_systems/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Constants for the floor_heating integration."""

DOMAIN = "lk_systems"
31 changes: 31 additions & 0 deletions homeassistant/components/lk_systems/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Coordinator module for the LK Systems integration."""

from datetime import timedelta
import logging

import aiohttp

from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

_LOGGER = logging.getLogger(__name__)


class LKSystemDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching LK Systems data."""

def __init__(self, hass, api):
"""Initialize the coordinator."""
self.api = api
super().__init__(
hass,
_LOGGER,
name="LK Systems",
update_interval=timedelta(seconds=30),
)

async def _async_update_data(self):
"""Fetch data from the API."""
try:
return await self.api.get_main_data()
except aiohttp.ContentTypeError as err:
raise UpdateFailed(f"Error fetching data: {err}") from err
11 changes: 11 additions & 0 deletions homeassistant/components/lk_systems/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Custom exceptions for the LK Systems integration."""

from homeassistant.exceptions import HomeAssistantError


class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""


class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid authentication."""
13 changes: 13 additions & 0 deletions homeassistant/components/lk_systems/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"domain": "lk_systems",
"name": "LK systems",
"codeowners": ["@simon-bonnedahl"],
"config_flow": true,
"dependencies": [],
"documentation": "https://www.home-assistant.io/integrations/lk_systems",
"homekit": {},
"iot_class": "cloud_polling",
"requirements": [],
"ssdp": [],
"zeroconf": []
}
Loading