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

Use only relevant IP for XmlRPC Server listening on #1655

Merged
merged 9 commits into from
Aug 22, 2024
Merged
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
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Version 2024.8.11 (2024-08-21)

- Make HEATING_COOLING visible for thermostats
- Use only relevant IP for XmlRPC Server listening on

# Version 2024.8.10 (2024-08-20)

Expand Down
2 changes: 0 additions & 2 deletions example_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,6 @@ async def example_run(self):
interface_configs={interface_config},
client_session=None,
default_callback_port=48888,
enable_server=False,
).create_central()

# Add callbacks to handle the events and see what happens on the system.
Expand All @@ -462,7 +461,6 @@ async def example_run(self):
client_config=_ClientConfig(
central=self.central,
interface_config=interface_config,
local_ip="127.0.0.1",
),
local_resources=local_resources,
)
Expand Down
97 changes: 47 additions & 50 deletions hahomematic/central/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from functools import partial
import logging
from logging import DEBUG
import socket
import threading
from time import sleep
from typing import Any, Final, cast
Expand Down Expand Up @@ -43,6 +42,8 @@
EVENT_INTERFACE_ID,
EVENT_TYPE,
IGNORE_FOR_UN_IGNORE_PARAMETERS,
IP_ANY_V4,
PORT_ANY,
UN_IGNORE_WILDCARD,
BackendSystemEvent,
Description,
Expand Down Expand Up @@ -79,6 +80,7 @@
get_channel_no,
get_device_address,
get_entity_key,
get_ip_addr,
reduce_args,
)

Expand Down Expand Up @@ -113,18 +115,9 @@ def __init__(self, central_config: CentralConfig) -> None:
self._model: str | None = None
self._connection_state: Final = central_config.connection_state
self._looper = Looper()
self._xml_rpc_server: Final = (
xmlrpc.create_xml_rpc_server(
local_port=central_config.callback_port or central_config.default_callback_port
)
if central_config.enable_server
else None
)
if self._xml_rpc_server:
self._xml_rpc_server.add_central(self)
self.local_port: Final[int] = (
self._xml_rpc_server.local_port if self._xml_rpc_server else 0
)
self._xml_rpc_server: xmlrpc.XmlRpcServer | None = None
self._xml_rpc_server_ip_addr: str = IP_ANY_V4
self._xml_rpc_server_port: int = PORT_ANY

# Caches for CCU data
self.data_cache: Final[CentralDataCache] = CentralDataCache(central=self)
Expand Down Expand Up @@ -273,6 +266,16 @@ def sysvar_entities(self) -> tuple[GenericSystemVariable, ...]:
"""Return the sysvar entities."""
return tuple(self._sysvar_entities.values())

@property
def xml_rpc_server_ip_addr(self) -> str:
"""Return the xml rpc server ip address."""
return self._xml_rpc_server_ip_addr

@property
def xml_rpc_server_port(self) -> int:
"""Return the xml rpc server port."""
return self._xml_rpc_server_port

def add_sysvar_entity(self, sysvar_entity: GenericSystemVariable) -> None:
"""Add new program button."""
if (ccu_var_name := sysvar_entity.ccu_var_name) is not None:
Expand Down Expand Up @@ -312,6 +315,24 @@ async def start(self) -> None:
if self._started:
_LOGGER.debug("START: Central %s already started", self._name)
return
if self.config.interface_configs and (
ip_addr := await self._identify_ip_addr(
port=tuple(self.config.interface_configs)[0].port
)
):
self._xml_rpc_server_ip_addr = ip_addr
self._xml_rpc_server = (
xmlrpc.create_xml_rpc_server(
ip_addr=ip_addr,
port=self.config.callback_port or self.config.default_callback_port,
)
if self.config.enable_server
else None
)
if self._xml_rpc_server:
self._xml_rpc_server.add_central(self)
self._xml_rpc_server_port = self._xml_rpc_server.port if self._xml_rpc_server else 0

await self.parameter_visibility.load()
if self.config.start_direct:
if await self._create_clients():
Expand Down Expand Up @@ -438,11 +459,11 @@ async def _create_clients(self) -> bool:
)
return False

local_ip = await self._identify_callback_ip(tuple(self.config.interface_configs)[0].port)
for interface_config in self.config.interface_configs:
try:
if client := await hmcl.create_client(
central=self, interface_config=interface_config, local_ip=local_ip
central=self,
interface_config=interface_config,
):
if (
available_interfaces := client.system_information.available_interfaces
Expand Down Expand Up @@ -517,42 +538,23 @@ def fire_interface_event(
event_data=cast(dict[str, Any], INTERFACE_EVENT_SCHEMA(event_data)),
)

async def _identify_callback_ip(self, port: int) -> str:
"""Identify local IP used for callbacks."""
async def _identify_ip_addr(self, port: int) -> str:
"""Identify IP used for callbacks, xmlrpc_server."""

# Do not add: pylint disable=no-member
# This is only an issue on macOS
def get_local_ip(host: str) -> str | None:
"""Get local_ip from socket."""
try:
socket.gethostbyname(host)
except Exception as exc:
message = f"GET_LOCAL_IP: Can't resolve host for {host}"
_LOGGER.warning(message)
raise HaHomematicException(message) from exc
tmp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
tmp_socket.settimeout(config.TIMEOUT)
tmp_socket.connect((host, port))
local_ip = str(tmp_socket.getsockname()[0])
tmp_socket.close()
_LOGGER.debug("GET_LOCAL_IP: Got local ip: %s", local_ip)
return local_ip

callback_ip: str | None = None
while callback_ip is None:
ip_addr: str | None = None
while ip_addr is None:
try:
callback_ip = await self.looper.async_add_executor_job(
get_local_ip, self.config.host, name="get_local_ip"
ip_addr = await self.looper.async_add_executor_job(
get_ip_addr, self.config.host, port, name="get_ip_addr"
)
except HaHomematicException:
callback_ip = "127.0.0.1"
if callback_ip is None:
ip_addr = "127.0.0.1"
if ip_addr is None:
_LOGGER.warning(
"GET_LOCAL_IP: Waiting for %i s,", config.CONNECTION_CHECKER_INTERVAL
"GET_IP_ADDR: Waiting for %i s,", config.CONNECTION_CHECKER_INTERVAL
)
await asyncio.sleep(config.CONNECTION_CHECKER_INTERVAL)

return callback_ip
return ip_addr

def _start_connection_checker(self) -> None:
"""Start the connection checker."""
Expand All @@ -576,14 +578,9 @@ async def validate_config_and_get_system_information(self) -> SystemInformation:
if len(self.config.interface_configs) == 0:
raise NoClients("validate_config: No clients defined.")

local_ip = await self._identify_callback_ip(
tuple(self.config.interface_configs)[0].port
)
system_information = SystemInformation()
for interface_config in self.config.interface_configs:
client = await hmcl.create_client(
central=self, interface_config=interface_config, local_ip=local_ip
)
client = await hmcl.create_client(central=self, interface_config=interface_config)
if not system_information.serial:
system_information = client.system_information
except NoClients:
Expand Down
48 changes: 34 additions & 14 deletions hahomematic/central/xml_rpc_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,21 +162,23 @@ class XmlRpcServer(threading.Thread):
"""XML-RPC server thread to handle messages from CCU / Homegear."""

_initialized: bool = False
_instances: Final[dict[int, XmlRpcServer]] = {}
_instances: Final[dict[tuple[str, int], XmlRpcServer]] = {}

def __init__(
self,
local_port: int = PORT_ANY,
ip_addr: str,
port: int,
) -> None:
"""Init XmlRPC server."""
if self._initialized:
return
self._initialized = True
self.local_port: Final[int] = find_free_port() if local_port == PORT_ANY else local_port
self._instances[self.local_port] = self
threading.Thread.__init__(self, name=f"XmlRpcServer on port {self.local_port}")
_port: Final[int] = find_free_port() if port == PORT_ANY else port
self._address: Final[tuple[str, int]] = (ip_addr, _port)
self._instances[self._address] = self
threading.Thread.__init__(self, name=f"XmlRpcServer {ip_addr}:{_port}")
self._simple_xml_rpc_server = HaHomematicXMLRPCServer(
(IP_ANY_V4, self.local_port),
addr=self._address,
requestHandler=RequestHandler,
logRequests=False,
allow_none=True,
Expand All @@ -186,16 +188,20 @@ def __init__(
self._simple_xml_rpc_server.register_instance(RPCFunctions(self), allow_dotted_names=True)
self._centrals: Final[dict[str, hmcu.CentralUnit]] = {}

def __new__(cls, local_port: int) -> XmlRpcServer: # noqa: PYI034
def __new__(cls, ip_addr: str, port: int) -> XmlRpcServer: # noqa: PYI034
"""Create new XmlRPC server."""
if (xml_rpc := cls._instances.get(local_port)) is None:
if (xml_rpc := cls._instances.get((ip_addr, port))) is None:
_LOGGER.debug("Creating XmlRpc server")
return super().__new__(cls)
return xml_rpc

def run(self) -> None:
"""Run the XmlRPC-Server thread."""
_LOGGER.debug("RUN: Starting XmlRPC-Server at http://%s:%i", IP_ANY_V4, self.local_port)
_LOGGER.debug(
"RUN: Starting XmlRPC-Server at http://%s:%i",
self.ip_addr,
self.port,
)
self._simple_xml_rpc_server.serve_forever()

def stop(self) -> None:
Expand All @@ -205,8 +211,18 @@ def stop(self) -> None:
_LOGGER.debug("STOP: Stopping XmlRPC-Server")
self._simple_xml_rpc_server.server_close()
_LOGGER.debug("STOP: XmlRPC-Server stopped")
if self.local_port in self._instances:
del self._instances[self.local_port]
if self._address in self._instances:
del self._instances[self._address]

@property
def ip_addr(self) -> str:
"""Return the ip address."""
return self._address[0]

@property
def port(self) -> int:
"""Return the local port."""
return self._address[1]

@property
def started(self) -> bool:
Expand Down Expand Up @@ -236,10 +252,14 @@ def no_central_assigned(self) -> bool:
return len(self._centrals) == 0


def create_xml_rpc_server(local_port: int = PORT_ANY) -> XmlRpcServer:
def create_xml_rpc_server(ip_addr: str = IP_ANY_V4, port: int = PORT_ANY) -> XmlRpcServer:
"""Register the xml rpc server."""
xml_rpc = XmlRpcServer(local_port=local_port)
xml_rpc = XmlRpcServer(ip_addr=ip_addr, port=port)
if not xml_rpc.started:
xml_rpc.start()
_LOGGER.debug("CREATE_XML_RPC_SERVER: Starting XmlRPC-Server")
_LOGGER.debug(
"CREATE_XML_RPC_SERVER: Starting XmlRPC-Server on %s:%i",
xml_rpc.ip_addr,
xml_rpc.port,
)
return xml_rpc
14 changes: 7 additions & 7 deletions hahomematic/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -992,7 +992,6 @@ def __init__(
self,
central: hmcu.CentralUnit,
interface_config: InterfaceConfig,
local_ip: str,
) -> None:
self.central: Final = central
self.version: str = "0"
Expand All @@ -1001,10 +1000,14 @@ def __init__(
self.interface: Final = interface_config.interface
self.interface_id: Final = interface_config.interface_id
self._callback_host: Final[str] = (
central.config.callback_host if central.config.callback_host else local_ip
central.config.callback_host
if central.config.callback_host
else central.xml_rpc_server_ip_addr
)
self._callback_port: Final[int] = (
central.config.callback_port if central.config.callback_port else central.local_port
central.config.callback_port
if central.config.callback_port
else central.xml_rpc_server_port
)
self.has_credentials: Final[bool] = (
central.config.username is not None and central.config.password is not None
Expand Down Expand Up @@ -1115,12 +1118,9 @@ def _init_validate(self) -> None:
async def create_client(
central: hmcu.CentralUnit,
interface_config: InterfaceConfig,
local_ip: str,
) -> Client:
"""Return a new client for with a given interface_config."""
return await _ClientConfig(
central=central, interface_config=interface_config, local_ip=local_ip
).get_client()
return await _ClientConfig(central=central, interface_config=interface_config).get_client()


def get_client(interface_id: str) -> Client | None:
Expand Down
18 changes: 18 additions & 0 deletions hahomematic/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import sys
from typing import Any, Final

from hahomematic.config import TIMEOUT
from hahomematic.const import (
CACHE_PATH,
CCU_PASSWORD_PATTERN,
Expand Down Expand Up @@ -214,6 +215,23 @@ def find_free_port() -> int:
return int(sock.getsockname()[1])


def get_ip_addr(host: str, port: int) -> str | None:
"""Get local_ip from socket."""
try:
socket.gethostbyname(host)
except Exception as exc:
message = f"GET_LOCAL_IP: Can't resolve host for {host}:{port}"
_LOGGER.warning(message)
raise HaHomematicException(message) from exc
tmp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
tmp_socket.settimeout(TIMEOUT)
tmp_socket.connect((host, port))
local_ip = str(tmp_socket.getsockname()[0])
tmp_socket.close()
_LOGGER.debug("GET_LOCAL_IP: Got local ip: %s", local_ip)
return local_ip


def element_matches_key(
search_elements: str | Collection[str],
compare_with: str | None,
Expand Down
3 changes: 1 addition & 2 deletions tests/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ async def get_unpatched_default_central(
client_config=_ClientConfig(
central=central,
interface_config=interface_config,
local_ip="127.0.0.1",
),
local_resources=LocalRessources(
address_device_translation=address_device_translation,
Expand Down Expand Up @@ -130,7 +129,7 @@ async def get_default_central(
return_value=const.PROGRAM_DATA if add_programs else [],
).start()
patch(
"hahomematic.central.CentralUnit._identify_callback_ip", return_value="127.0.0.1"
"hahomematic.central.CentralUnit._identify_ip_addr", return_value="127.0.0.1"
).start()

await central.start()
Expand Down
8 changes: 4 additions & 4 deletions tests/test_central.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,14 @@ async def test_device_export(
(TEST_DEVICES, True, False, False, None, None),
],
)
async def test_identify_callback_ip(
async def test_identify_ip_addr(
central_client_factory: tuple[CentralUnit, Client | Mock, helper.Factory],
) -> None:
"""Test identify_callback_ip."""
"""Test identify_ip_addr."""
central, _, _ = central_client_factory
assert await central._identify_callback_ip(port=54321) == "127.0.0.1"
assert await central._identify_ip_addr(port=54321) == "127.0.0.1"
central.config.host = "no_host"
assert await central._identify_callback_ip(port=54321) == "127.0.0.1"
assert await central._identify_ip_addr(port=54321) == "127.0.0.1"


@pytest.mark.parametrize(
Expand Down