Repository: XKNX/xknx
Pull request #428 (2020-09-25 15:43:08)

Refactor logging in xknx
This commit aims to remove some of the tight coupling between XKNX and all our subpackages.
We now use a dedicated logger for every module.

Additionally, when we use `XKNX(log_to_file=True)` we log each and everything to a dedicated file
called xknx.log that will be kept for 7 days and will be rotated every night.

The default for this is False.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/33726146/badge)](https://coveralls.io/builds/33726146)

Coverage decreased (-0.08%) to 92.071% when pulling **eb406dc45f6ada05b4590287264bba399e3d4abb on marvin-w:feature/refactor_logging** into **e717fb872cc0c958774f95aaab9166c109027b15 on XKNX:master**.


Patch:
diff --git a/.gitignore b/.gitignore
index 7834c99b..f218c122 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,4 @@ venv
 .mypy_cache/
 .python-version
 *.iml
+*.log
diff --git a/.pylintrc b/.pylintrc
index deac14bc..6e22a9ee 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -43,7 +43,9 @@ disable=
   too-many-statements,
   too-many-boolean-expressions,
   unused-argument,
-  wrong-import-order
+  wrong-import-order,
+  logging-not-lazy,
+  logging-fstring-interpolation
 # enable useless-suppression temporarily every now and then to clean them up
 enable=
   use-symbolic-message-instead
diff --git a/docs/xknx.md b/docs/xknx.md
index 6cb4e872..d3387218 100644
--- a/docs/xknx.md
+++ b/docs/xknx.md
@@ -27,7 +27,8 @@ xknx = XKNX(config='xknx.yaml',
             device_updated_cb=None,
             rate_limit=DEFAULT_RATE_LIMIT,
             multicast_group=DEFAULT_MCAST_GRP,
-            multicast_port=DEFAULT_MCAST_PORT)
+            multicast_port=DEFAULT_MCAST_PORT,
+            log_directory=None)
 ```
 
 The constructor of the XKNX object takes several parameters:
@@ -44,6 +45,7 @@ The constructor of the XKNX object takes several parameters:
 * `rate_limit` in telegrams per second - can be used to limit the outgoing traffic to the KNX/IP interface. The default value is 20 packets per second.
 * `multicast_group` is the multicast IP address - can be used to override the default multicast address (`224.0.23.12`)
 * `multicast_port` is the multicast port - can be used to override the default multicast port (`3671`)
+* `log_directory` is the path to the log directory - when set to a valid directory we log to a dedicated file in this directory called `xknx.log`. The log files are rotated each night and will exist for 7 days. After that the oldest one will be deleted.
 
 # [](#header-2)Starting
 
diff --git a/xknx/core/config.py b/xknx/core/config.py
index a819a51d..957b3925 100644
--- a/xknx/core/config.py
+++ b/xknx/core/config.py
@@ -5,6 +5,7 @@ Module for reading config files (xknx.yaml).
 * and add the found devices to the devies vector of XKNX.
 """
 from enum import Enum
+import logging
 
 from xknx.devices import (
     BinarySensor,
@@ -24,6 +25,8 @@ from xknx.exceptions import XKNXException
 from xknx.io import ConnectionConfig, ConnectionType
 import yaml
 
+logger = logging.getLogger("xknx_log")
+
 
 class Version(Enum):
     """The used xknx.yaml structure version."""
@@ -41,13 +44,13 @@ class Config:
 
     def read(self, file="xknx.yaml"):
         """Read config."""
-        self.xknx.logger.debug("Reading %s", file)
+        logger.debug("Reading %s", file)
         try:
             with open(file) as filehandle:
                 doc = yaml.safe_load(filehandle)
                 self.parse(doc)
         except FileNotFoundError as ex:
-            self.xknx.logger.error("Error while reading %s: %s", file, ex)
+            logger.error("Error while reading %s: %s", file, ex)
 
     @staticmethod
     def parse_version(doc):
@@ -96,7 +99,7 @@ class Config:
                         conn_type = ConnectionType.AUTOMATIC
                     self._parse_connection_prefs(conn_type, prefs)
                 except XKNXException as ex:
-                    self.xknx.logger.error(
+                    logger.error(
                         "Error while reading config file: Could not parse %s: %s",
                         conn,
                         ex,
@@ -150,7 +153,7 @@ class Config:
             elif group.startswith("weather"):
                 self.parse_group_weather(doc["groups"][group])
         except XKNXException as ex:
-            self.xknx.logger.error(
+            logger.error(
                 "Error while reading config file: Could not parse %s: %s", group, ex
             )
 
diff --git a/xknx/core/state_updater.py b/xknx/core/state_updater.py
index 0d8baa02..6b7af52d 100644
--- a/xknx/core/state_updater.py
+++ b/xknx/core/state_updater.py
@@ -2,6 +2,7 @@
 import asyncio
 from enum import Enum
 from functools import partial
+import logging
 
 from xknx.exceptions import ConversionError
 from xknx.remote_value import RemoteValue
@@ -9,6 +10,8 @@ from xknx.telegram import GroupAddress
 
 DEFAULT_UPDATE_INTERVAL = 60
 
+logger = logging.getLogger("xknx_log")
+
 
 class StateUpdater:
     """Class for keeping the states of RemoteValues up to date."""
@@ -41,7 +44,7 @@ class StateUpdater:
                 elif _options[0].upper() == "EVERY":
                     tracker_type = StateTrackerType.PERIODICALLY
                 else:
-                    self.xknx.logger.warning(
+                    logger.warning(
                         'Could not parse StateUpdater tracker_options "%s" for %s. Using default %s %s minutes.'
                         % (tracker_options, remote_value, tracker_type, update_interval)
                     )
@@ -52,7 +55,7 @@ class StateUpdater:
                 except IndexError:
                     pass  # No time given (no _options[1])
             else:
-                self.xknx.logger.warning(
+                logger.warning(
                     'Could not parse StateUpdater tracker_options type %s "%s" for %s. Using default %s %s minutes.'
                     % (
                         type(tracker_options),
@@ -76,7 +79,7 @@ class StateUpdater:
         )
         self._workers[id(remote_value)] = tracker
 
-        self.xknx.logger.debug(
+        logger.debug(
             f"StateUpdater registered {tracker_type} {update_inteval} for {remote_value}"
         )
         if self.started:
@@ -93,14 +96,14 @@ class StateUpdater:
 
     def start(self):
         """Start StateUpdater. Initialize states."""
-        self.xknx.logger.debug("StateUpdater initializing values")
+        logger.debug("StateUpdater initializing values")
         self.started = True
         for worker in self._workers.values():
             worker.start()
 
     def stop(self):
         """Stop StateUpdater."""
-        self.xknx.logger.debug("StateUpdater stopping")
+        logger.debug("StateUpdater stopping")
         self.started = False
         for worker in self._workers.values():
             worker.stop()
@@ -148,7 +151,7 @@ class _StateTracker:
 
     def start(self):
         """Start StateTracker - read state on call."""
-        self.xknx.logger.debug(
+        logger.debug(
             "StateUpdater initializing %s for %s - %s"
             % (self.group_address, self.device_name, self.feature_name)
         )
@@ -189,7 +192,7 @@ class _StateTracker:
 
     def _read_state(self):
         """Schedule to read the state from the KNX bus."""
-        self.xknx.logger.debug(
+        logger.debug(
             "StateUpdater scheduled reading %s for %s - %s"
             % (self.group_address, self.device_name, self.feature_name)
         )
diff --git a/xknx/core/telegram_queue.py b/xknx/core/telegram_queue.py
index f8523b15..001f5947 100644
--- a/xknx/core/telegram_queue.py
+++ b/xknx/core/telegram_queue.py
@@ -8,10 +8,14 @@ The underlaying KNXIPInterface will poll the queue and send the packets to the c
 You may register callbacks to be notified if a telegram was pushed to the queue.
 """
 import asyncio
+import logging
 
 from xknx.exceptions import CommunicationError, XKNXException
 from xknx.telegram import TelegramDirection, TelegramType
 
+logger = logging.getLogger("xknx_log")
+telegram_logger = logging.getLogger("xknx_telegram")
+
 
 class TelegramQueue:
     """Class for telegram queue."""
@@ -60,7 +64,7 @@ class TelegramQueue:
 
     async def stop(self):
         """Stop telegram queue."""
-        self.xknx.logger.debug("Stopping TelegramQueue")
+        logger.debug("Stopping TelegramQueue")
         # If a None object is pushed to the queue, the queue stops
         await self.xknx.telegrams.put(None)
         await self._consumer_task
@@ -84,7 +88,7 @@ class TelegramQueue:
                     self.outgoing_queue.put_nowait(telegram)
                     # self.xknx.telegrams.task_done() for outgoing is called in _outgoing_rate_limiter.
             except XKNXException as ex:
-                self.xknx.logger.error("Error while processing telegram %s", ex)
+                logger.error("Error while processing telegram %s", ex)
 
     async def _outgoing_rate_limiter(self):
         """Endless loop for processing outgoing telegrams."""
@@ -99,11 +103,9 @@ class TelegramQueue:
                 await self.process_telegram_outgoing(telegram)
             except CommunicationError as ex:
                 if ex.should_log:
-                    self.xknx.logger.warning(ex)
+                    logger.warning(ex)
             except XKNXException as ex:
-                self.xknx.logger.error(
-                    "Error while processing outgoing telegram %s", ex
-                )
+                logger.error("Error while processing outgoing telegram %s", ex)
             finally:
                 self.outgoing_queue.task_done()
                 self.xknx.telegrams.task_done()
@@ -122,23 +124,23 @@ class TelegramQueue:
                 elif telegram.direction == TelegramDirection.OUTGOING:
                     await self.process_telegram_outgoing(telegram)
             except XKNXException as ex:
-                self.xknx.logger.error("Error while processing telegram %s", ex)
+                logger.error("Error while processing telegram %s", ex)
             finally:
                 self.xknx.telegrams.task_done()
 
     async def process_telegram_outgoing(self, telegram):
         """Process outgoing telegram."""
-        self.xknx.telegram_logger.debug(telegram)
+        telegram_logger.debug(telegram)
         if self.xknx.knxip_interface is not None:
             await self.xknx.knxip_interface.send_telegram(telegram)
             if telegram.telegramtype == TelegramType.GROUP_WRITE:
                 await self.xknx.devices.process(telegram)
         else:
-            self.xknx.logger.warning("No KNXIP interface defined")
+            logger.warning("No KNXIP interface defined")
 
     async def process_telegram_incoming(self, telegram):
         """Process incoming telegram."""
-        self.xknx.telegram_logger.debug(telegram)
+        telegram_logger.debug(telegram)
         processed = False
         for telegram_received_cb in self.telegram_received_cbs:
             if telegram_received_cb.is_within_filter(telegram):
diff --git a/xknx/core/value_reader.py b/xknx/core/value_reader.py
index 596ee62c..22346ed5 100644
--- a/xknx/core/value_reader.py
+++ b/xknx/core/value_reader.py
@@ -7,11 +7,13 @@ The module will
 * ... check if received telegrams have the correct group address.
 * ... store the received telegram for further processing.
 """
-
 import asyncio
+import logging
 
 from xknx.telegram import Telegram, TelegramType
 
+logger = logging.getLogger("xknx_log")
+
 
 class ValueReader:
     """Class for reading the value of a specific KNX group address from KNX bus."""
@@ -65,7 +67,7 @@ class ValueReader:
 
     def timeout(self):
         """Handle timeout for not having received expected group response."""
-        self.xknx.logger.warning(
+        logger.warning(
             "Error: KNX bus did not respond in time (%s secs) to GroupValueRead request for: %s",
             self.timeout_in_seconds,
             self.group_address,
diff --git a/xknx/devices/action.py b/xknx/devices/action.py
index dc79c97b..e7be5a70 100644
--- a/xknx/devices/action.py
+++ b/xknx/devices/action.py
@@ -1,4 +1,7 @@
 """Module for handling commands which may be attached to BinarySensor class."""
+import logging
+
+logger = logging.getLogger("xknx_log")
 
 
 class ActionBase:
@@ -29,7 +32,7 @@ class ActionBase:
 
     async def execute(self):
         """Execute action. To be overwritten in derived classes."""
-        self.xknx.logger.info("Execute not implemented for %s", self.__class__.__name__)
+        logger.info("Execute not implemented for %s", self.__class__.__name__)
 
     def __str__(self):
         """Return object as readable string."""
@@ -63,9 +66,7 @@ class Action(ActionBase):
         """Execute action."""
         if self.target is not None:
             if self.target not in self.xknx.devices:
-                self.xknx.logger.warning(
-                    "Unknown device %s witin action %s.", self.target, self
-                )
+                logger.warning("Unknown device %s witin action %s.", self.target, self)
                 return
             await self.xknx.devices[self.target].do(self.method)
 
diff --git a/xknx/devices/climate.py b/xknx/devices/climate.py
index 9aaa107c..f54aaa9b 100644
--- a/xknx/devices/climate.py
+++ b/xknx/devices/climate.py
@@ -5,6 +5,7 @@ Module for managing the climate within a room.
 * Manages and sends the desired setpoint to KNX bus.
 """
 from enum import Enum
+import logging
 
 from xknx.remote_value import (
     RemoteValueSetpointShift,
@@ -15,6 +16,8 @@ from xknx.remote_value import (
 from .climate_mode import ClimateMode
 from .device import Device
 
+logger = logging.getLogger("xknx_log")
+
 
 class SetpointShiftMode(Enum):
     """Enum for setting the setpoint shift mode."""
@@ -245,10 +248,10 @@ class Climate(Device):
     def validate_value(self, value, min_value, max_value):
         """Check boundaries of temperature and return valid temperature value."""
         if (min_value is not None) and (value < min_value):
-            self.xknx.logger.warning("Min value exceeded at %s: %s", self.name, value)
+            logger.warning("Min value exceeded at %s: %s", self.name, value)
             return min_value
         if (max_value is not None) and (value > max_value):
-            self.xknx.logger.warning("Max value exceeded at %s: %s", self.name, value)
+            logger.warning("Max value exceeded at %s: %s", self.name, value)
             return max_value
         return value
 
diff --git a/xknx/devices/cover.py b/xknx/devices/cover.py
index 1ee07d4f..5e1709da 100644
--- a/xknx/devices/cover.py
+++ b/xknx/devices/cover.py
@@ -7,6 +7,8 @@ It provides functionality for
 * reading the current state from KNX bus.
 * Cover will also predict the current position.
 """
+import logging
+
 from xknx.remote_value import (
     RemoteValueScaling,
     RemoteValueStep,
@@ -17,6 +19,8 @@ from xknx.remote_value import (
 from .device import Device
 from .travelcalculator import TravelCalculator
 
+logger = logging.getLogger("xknx_log")
+
 
 class Cover(Device):
     """Class for managing a cover."""
@@ -190,9 +194,7 @@ class Cover(Device):
         elif self.step.writable:
             await self.step.increase()
         else:
-            self.xknx.logger.warning(
-                "Stop not supported for device %s", self.get_name()
-            )
+            logger.warning("Stop not supported for device %s", self.get_name())
             return
         self.travelcalculator.stop()
         await self.after_update()
@@ -209,7 +211,7 @@ class Cover(Device):
                 elif position == self.travelcalculator.position_closed:
                     await self.updown.down()
                 else:
-                    self.xknx.logger.warning(
+                    logger.warning(
                         "Current position unknown. Initialize cover by moving to end position."
                     )
                     return
@@ -225,9 +227,7 @@ class Cover(Device):
     async def set_angle(self, angle):
         """Move cover to designated angle."""
         if not self.supports_angle:
-            self.xknx.logger.warning(
-                "Angle not supported for device %s", self.get_name()
-            )
+            logger.warning("Angle not supported for device %s", self.get_name())
             return
         await self.angle.set(angle)
 
@@ -259,7 +259,7 @@ class Cover(Device):
         elif action == "stop":
             await self.stop()
         else:
-            self.xknx.logger.warning(
+            logger.warning(
                 "Could not understand action %s for device %s", action, self.get_name()
             )
 
diff --git a/xknx/devices/device.py b/xknx/devices/device.py
index 110c0f06..d01dbad5 100644
--- a/xknx/devices/device.py
+++ b/xknx/devices/device.py
@@ -3,8 +3,12 @@ Device is the base class for all implemented devices (e.g. Lights/Switches/Senso
 
 It provides basis functionality for reading the state from the KNX bus.
 """
+import logging
+
 from xknx.telegram import TelegramType
 
+logger = logging.getLogger("xknx_log")
+
 
 class Device:
     """Base class for devices."""
@@ -81,7 +85,7 @@ class Device:
     async def do(self, action):
         """Execute 'do' commands."""
         # pylint: disable=invalid-name
-        self.xknx.logger.info(
+        logger.info(
             "'do()' not implemented for action '%s' of %s",
             action,
             self.__class__.__name__,
diff --git a/xknx/devices/fan.py b/xknx/devices/fan.py
index 1122637d..9c4393bf 100644
--- a/xknx/devices/fan.py
+++ b/xknx/devices/fan.py
@@ -6,10 +6,14 @@ It provides functionality for
 * setting fan to specific speed
 * reading the current speed from KNX bus.
 """
+import logging
+
 from xknx.remote_value import RemoteValueScaling
 
 from .device import Device
 
+logger = logging.getLogger("xknx_log")
+
 
 class Fan(Device):
     """Class for managing a fan."""
@@ -72,7 +76,7 @@ class Fan(Device):
         if action.startswith("speed:"):
             await self.set_speed(int(action[6:]))
         else:
-            self.xknx.logger.warning(
+            logger.warning(
                 "Could not understand action %s for device %s", action, self.get_name()
             )
 
diff --git a/xknx/devices/light.py b/xknx/devices/light.py
index 1332a722..8d67c4d1 100644
--- a/xknx/devices/light.py
+++ b/xknx/devices/light.py
@@ -10,6 +10,8 @@ It provides functionality for
 * setting the absolute color temperature.
 * reading the current state from KNX bus.
 """
+import logging
+
 from xknx.remote_value import (
     RemoteValueColorRGB,
     RemoteValueColorRGBW,
@@ -20,6 +22,8 @@ from xknx.remote_value import (
 
 from .device import Device
 
+logger = logging.getLogger("xknx_log")
+
 
 # pylint: disable=too-many-public-methods, too-many-instance-attributes
 class Light(Device):
@@ -251,9 +255,7 @@ class Light(Device):
     async def set_brightness(self, brightness):
         """Set brightness of light."""
         if not self.supports_brightness:
-            self.xknx.logger.warning(
-                "Dimming not supported for device %s", self.get_name()
-            )
+            logger.warning("Dimming not supported for device %s", self.get_name())
             return
         await self.brightness.set(brightness)
 
@@ -281,16 +283,12 @@ class Light(Device):
             if self.supports_rgbw:
                 await self.rgbw.set(list(color) + [white])
                 return
-            self.xknx.logger.warning(
-                "RGBW not supported for device %s", self.get_name()
-            )
+            logger.warning("RGBW not supported for device %s", self.get_name())
         else:
             if self.supports_color:
                 await self.color.set(color)
                 return
-            self.xknx.logger.warning(
-                "Colors not supported for device %s", self.get_name()
-            )
+            logger.warning("Colors not supported for device %s", self.get_name())
 
     @property
     def current_tunable_white(self):
@@ -300,9 +298,7 @@ class Light(Device):
     async def set_tunable_white(self, tunable_white):
         """Set relative color temperature of light."""
         if not self.supports_tunable_white:
-            self.xknx.logger.warning(
-                "Tunable white not supported for device %s", self.get_name()
-            )
+            logger.warning("Tunable white not supported for device %s", self.get_name())
             return
         await self.tunable_white.set(tunable_white)
 
@@ -314,7 +310,7 @@ class Light(Device):
     async def set_color_temperature(self, color_temperature):
         """Set absolute color temperature of light."""
         if not self.supports_color_temperature:
-            self.xknx.logger.warning(
+            logger.warning(
                 "Absolute Color Temperature not supported for device %s",
                 self.get_name(),
             )
@@ -334,7 +330,7 @@ class Light(Device):
         elif action.startswith("color_temperature:"):
             await self.set_color_temperature(int(action[18:]))
         else:
-            self.xknx.logger.warning(
+            logger.warning(
                 "Could not understand action %s for device %s", action, self.get_name()
             )
 
diff --git a/xknx/devices/notification.py b/xknx/devices/notification.py
index 8b888d94..43615d4a 100644
--- a/xknx/devices/notification.py
+++ b/xknx/devices/notification.py
@@ -1,8 +1,12 @@
 """Module for managing a notification via KNX."""
+import logging
+
 from xknx.remote_value import RemoteValueString
 
 from .device import Device
 
+logger = logging.getLogger("xknx_log")
+
 
 class Notification(Device):
     """Class for managing a notification."""
@@ -64,7 +68,7 @@ class Notification(Device):
         if action.startswith("message:"):
             await self.set(action[8:])
         else:
-            self.xknx.logger.warning(
+            logger.warning(
                 "Could not understand action %s for device %s", action, self.get_name()
             )
 
diff --git a/xknx/devices/scene.py b/xknx/devices/scene.py
index f040a373..eb9c0426 100644
--- a/xknx/devices/scene.py
+++ b/xknx/devices/scene.py
@@ -1,8 +1,12 @@
 """Module for managing a KNX scene."""
+import logging
+
 from xknx.remote_value import RemoteValueSceneNumber
 
 from .device import Device
 
+logger = logging.getLogger("xknx_log")
+
 
 class Scene(Device):
     """Class for managing a scene."""
@@ -52,6 +56,6 @@ class Scene(Device):
         if action == "run":
             await self.run()
         else:
-            self.xknx.logger.warning(
+            logger.warning(
                 "Could not understand action %s for device %s", action, self.get_name()
             )
diff --git a/xknx/devices/switch.py b/xknx/devices/switch.py
index 468056d0..81d41d1d 100644
--- a/xknx/devices/switch.py
+++ b/xknx/devices/switch.py
@@ -6,10 +6,14 @@ It provides functionality for
 * switching 'on' and 'off'.
 * reading the current state from KNX bus.
 """
+import logging
+
 from xknx.remote_value import RemoteValueSwitch
 
 from .device import Device
 
+logger = logging.getLogger("xknx_log")
+
 
 class Switch(Device):
     """Class for managing a switch."""
@@ -72,7 +76,7 @@ class Switch(Device):
         elif action == "off":
             await self.set_off()
         else:
-            self.xknx.logger.warning(
+            logger.warning(
                 "Could not understand action %s for device %s", action, self.get_name()
             )
 
diff --git a/xknx/io/connect.py b/xknx/io/connect.py
index d54f1146..dc654e23 100644
--- a/xknx/io/connect.py
+++ b/xknx/io/connect.py
@@ -15,7 +15,6 @@ class Connect(RequestResponse):
 
     def __init__(self, xknx, udp_client):
         """Initialize Connect class."""
-        self.xknx = xknx
         self.udp_client = udp_client
         super().__init__(xknx, self.udp_client, ConnectResponse)
         self.communication_channel = 0
diff --git a/xknx/io/gateway_scanner.py b/xknx/io/gateway_scanner.py
index a9b4d234..d5520aa8 100644
--- a/xknx/io/gateway_scanner.py
+++ b/xknx/io/gateway_scanner.py
@@ -7,6 +7,7 @@ GatewayScanner is an abstraction for searching for KNX/IP devices on the local n
 """
 
 import asyncio
+import logging
 from typing import List, Optional
 
 import netifaces
@@ -22,6 +23,8 @@ from xknx.knxip import (
 
 from .udp_client import UDPClient
 
+logger = logging.getLogger("xknx_log")
+
 
 class GatewayDescriptor:
     """Used to return information about the discovered gateways."""
@@ -139,14 +142,12 @@ class GatewayScanner:
                 ip_addr = af_inet[0]["addr"]
                 await self._search_interface(interface, ip_addr)
             except KeyError:
-                self.xknx.logger.info(
-                    "Could not connect to an KNX/IP device on %s", interface
-                )
+                logger.info("Could not connect to an KNX/IP device on %s", interface)
                 continue
 
     async def _search_interface(self, interface, ip_addr):
         """Send a search request on a specific interface."""
-        self.xknx.logger.debug("Searching on %s / %s", interface, ip_addr)
+        logger.debug("Searching on %s / %s", interface, ip_addr)
 
         udp_client = UDPClient(
             self.xknx,
@@ -173,7 +174,7 @@ class GatewayScanner:
     ) -> None:
         """Verify and handle knxipframe. Callback from internal udpclient."""
         if not isinstance(knx_ip_frame.body, SearchResponse):
-            self.xknx.logger.warning("Could not understand knxipframe")
+            logger.warning("Could not understand knxipframe")
             return
 
         gateway = GatewayDescriptor(
diff --git a/xknx/io/knxip_interface.py b/xknx/io/knxip_interface.py
index 198c4592..179ed998 100644
--- a/xknx/io/knxip_interface.py
+++ b/xknx/io/knxip_interface.py
@@ -8,6 +8,7 @@ KNXIPInterface manages KNX/IP Tunneling or Routing connections.
 """
 from enum import Enum
 import ipaddress
+import logging
 
 import netifaces
 from xknx.exceptions import XKNXException
@@ -17,6 +18,8 @@ from .gateway_scanner import GatewayScanFilter, GatewayScanner
 from .routing import Routing
 from .tunnel import Tunnel
 
+logger = logging.getLogger("xknx_log")
+
 
 class ConnectionType(Enum):
     """Enum class for different types of KNX/IP Connections."""
@@ -135,7 +138,7 @@ class KNXIPInterface:
         if local_ip is None:
             local_ip = self.find_local_ip(gateway_ip=gateway_ip)
         validate_ip(local_ip, address_name="Local IP address")
-        self.xknx.logger.debug(
+        logger.debug(
             "Starting tunnel from %s to %s:%s", local_ip, gateway_ip, gateway_port
         )
         self.interface = Tunnel(
@@ -152,7 +155,7 @@ class KNXIPInterface:
     async def start_routing(self, local_ip):
         """Start KNX/IP Routing."""
         validate_ip(local_ip, address_name="Local IP address")
-        self.xknx.logger.debug("Starting Routing from %s", local_ip)
+        logger.debug("Starting Routing from %s", local_ip)
         self.interface = Routing(self.xknx, self.telegram_received, local_ip)
         await self.interface.start()
 
@@ -170,7 +173,8 @@ class KNXIPInterface:
         """Send telegram to connected device (either Tunneling or Routing)."""
         await self.interface.send_telegram(telegram)
 
-    def find_local_ip(self, gateway_ip: str) -> str:
+    @staticmethod
+    def find_local_ip(gateway_ip: str) -> str:
         """Find local IP address on same subnet as gateway."""
 
         def _scan_interfaces(gateway: ipaddress.IPv4Address) -> str:
@@ -183,10 +187,10 @@ class KNXIPInterface:
                             (link["addr"], link["netmask"]), strict=False
                         )
                         if gateway in network:
-                            self.xknx.logger.debug("Using interface: %s", interface)
+                            logger.debug("Using interface: %s", interface)
                             return link["addr"]
                 except KeyError:
-                    self.xknx.logger.debug(
+                    logger.debug(
                         "Could not find IPv4 address on interface %s", interface
                     )
                     continue
@@ -200,7 +204,7 @@ class KNXIPInterface:
         gateway = ipaddress.IPv4Address(gateway_ip)
         local_ip = _scan_interfaces(gateway)
         if local_ip is None:
-            self.xknx.logger.warning(
+            logger.warning(
                 "No interface on same subnet as gateway found. Falling back to default gateway."
             )
             default_gateway = _find_default_gateway()
diff --git a/xknx/io/request_response.py b/xknx/io/request_response.py
index 8f8c12d9..2ca0322f 100644
--- a/xknx/io/request_response.py
+++ b/xknx/io/request_response.py
@@ -4,9 +4,12 @@ Base class for sending a specific type of KNX/IP Packet to a KNX/IP device and w
 Will report if the corresponding answer was not received.
 """
 import asyncio
+import logging
 
 from xknx.knxip import ErrorCode, KNXIPFrame
 
+logger = logging.getLogger("xknx_log")
+
 
 class RequestResponse:
     """Class for sending a specific type of KNX/IP Packet to a KNX/IP device and wait for the corresponding answer."""
@@ -45,7 +48,7 @@ class RequestResponse:
     def response_rec_callback(self, knxipframe, _):
         """Verify and handle knxipframe. Callback from internal udpclient."""
         if not isinstance(knxipframe.body, self.awaited_response_class):
-            self.xknx.logger.warning("Could not understand knxipframe")
+            logger.warning("Could not understand knxipframe")
             return
         self.response_received_or_timeout.set()
         if knxipframe.body.status_code == ErrorCode.E_NO_ERROR:
@@ -59,7 +62,7 @@ class RequestResponse:
 
     def on_error_hook(self, knxipframe):
         """Do something after having received error within given time. May be overwritten in derived class."""
-        self.xknx.logger.debug(
+        logger.debug(
             "Error: KNX bus responded to request of type '%s' with error in '%s': %s",
             self.__class__.__name__,
             self.awaited_response_class.__name__,
@@ -68,7 +71,7 @@ class RequestResponse:
 
     def timeout(self):
         """Handle timeout for not having received expected knxipframe."""
-        self.xknx.logger.debug(
+        logger.debug(
             "Error: KNX bus did not respond in time (%s secs) to request of type '%s'",
             self.timeout_in_seconds,
             self.__class__.__name__,
diff --git a/xknx/io/routing.py b/xknx/io/routing.py
index 6fc2773f..602b8d7b 100644
--- a/xknx/io/routing.py
+++ b/xknx/io/routing.py
@@ -3,6 +3,8 @@ Abstraction for handling KNX/IP routing.
 
 Routing uses UDP Multicast to broadcast and receive KNX/IP messages.
 """
+import logging
+
 from xknx.knxip import (
     APCICommand,
     CEMIFrame,
@@ -15,6 +17,8 @@ from xknx.telegram import TelegramDirection
 
 from .udp_client import UDPClient
 
+logger = logging.getLogger("xknx_log")
+
 
 class Routing:
     """Class for handling KNX/IP routing."""
@@ -39,18 +43,18 @@ class Routing:
     def response_rec_callback(self, knxipframe, _):
         """Verify and handle knxipframe. Callback from internal udpclient."""
         if knxipframe.header.service_type_ident != KNXIPServiceType.ROUTING_INDICATION:
-            self.xknx.logger.warning("Service type not implemented: %s", knxipframe)
+            logger.warning("Service type not implemented: %s", knxipframe)
         elif knxipframe.body.cemi is None:
             # ignore unsupported CEMI frame
             return
         elif knxipframe.body.cemi.src_addr == self.xknx.own_address:
-            self.xknx.logger.debug("Ignoring own packet")
+            logger.debug("Ignoring own packet")
         elif knxipframe.body.cemi.cmd not in (
             APCICommand.GROUP_READ,
             APCICommand.GROUP_WRITE,
             APCICommand.GROUP_RESPONSE,
         ):
-            self.xknx.logger.warning("APCI not implemented: %s", knxipframe)
+            logger.warning("APCI not implemented: %s", knxipframe)
         else:
             telegram = knxipframe.body.cemi.telegram
             telegram.direction = TelegramDirection.INCOMING
diff --git a/xknx/io/tunnel.py b/xknx/io/tunnel.py
index ce22c2fa..00ac6a22 100644
--- a/xknx/io/tunnel.py
+++ b/xknx/io/tunnel.py
@@ -4,6 +4,7 @@ Abstraction for handling KNX/IP tunnels.
 Tunnels connect to KNX/IP devices directly via UDP and build a static UDP connection.
 """
 import asyncio
+import logging
 
 from xknx.exceptions import CommunicationError, XKNXException
 from xknx.knxip import (
@@ -21,6 +22,8 @@ from .disconnect import Disconnect
 from .tunnelling import Tunnelling
 from .udp_client import UDPClient
 
+logger = logging.getLogger("xknx_log")
+
 
 class Tunnel:
     """Class for handling KNX/IP tunnels."""
@@ -75,7 +78,7 @@ class Tunnel:
         """Handle incoming tunnel request."""
         # pylint: disable=unused-argument
         if knxipframe.header.service_type_ident != KNXIPServiceType.TUNNELLING_REQUEST:
-            self.xknx.logger.warning("Service not implemented: %s", knxipframe)
+            logger.warning("Service not implemented: %s", knxipframe)
         else:
             self.send_ack(
                 knxipframe.body.communication_channel_id,
@@ -119,20 +122,20 @@ class Tunnel:
                 msg = "Could not connect to KNX. Retry in {} seconds.".format(
                     self.auto_reconnect_wait
                 )
-                self.xknx.logger.warning(msg)
+                logger.warning(msg)
                 task = self.xknx.loop.create_task(self.schedule_reconnect())
                 self._reconnect_task = task
                 return
             raise CommunicationError(
                 "Could not establish connection", not self._is_reconnecting
             )
-        self.xknx.logger.debug(
+        logger.debug(
             "Tunnel established communication_channel=%s, id=%s",
             connect.communication_channel,
             connect.identifier,
         )
         if self._is_reconnecting:
-            self.xknx.logger.info("Successfully reconnected to KNX bus.")
+            logger.info("Successfully reconnected to KNX bus.")
         self._reconnect_task = None
         self._is_reconnecting = False
         self.communication_channel = connect.communication_channel
@@ -158,14 +161,10 @@ class Tunnel:
         """
         success = await self._send_telegram_impl(telegram)
         if not success:
-            self.xknx.logger.debug(
-                "Sending of telegram failed. Retrying a second time."
-            )
+            logger.debug("Sending of telegram failed. Retrying a second time.")
             success = await self._send_telegram_impl(telegram)
             if not success:
-                self.xknx.logger.debug(
-                    "Resending telegram failed. Reconnecting to tunnel."
-                )
+                logger.debug("Resending telegram failed. Reconnecting to tunnel.")
                 await self.reconnect()
                 success = await self._send_telegram_impl(telegram)
                 if not success:
@@ -218,7 +217,7 @@ class Tunnel:
         await disconnect.start()
         if not disconnect.success and not ignore_error:
             raise XKNXException("Could not disconnect channel")
-        self.xknx.logger.debug(
+        logger.debug(
             "Tunnel disconnected (communication_channel: %s)",
             self.communication_channel,
         )
@@ -287,7 +286,7 @@ class Tunnel:
         self.number_heartbeat_failed = self.number_heartbeat_failed + 1
         if self.number_heartbeat_failed > 3:
             if not self._is_reconnecting:
-                self.xknx.logger.warning("Heartbeat to KNX bus failed. Reconnecting.")
+                logger.warning("Heartbeat to KNX bus failed. Reconnecting.")
             try:
                 await self.stop_reconnect()
                 await self.reconnect()
@@ -295,4 +294,4 @@ class Tunnel:
                 await self.stop_heartbeat()
             except CommunicationError as exc:
                 if exc.should_log:
-                    self.xknx.logger.warning(exc)
+                    logger.warning(exc)
diff --git a/xknx/io/udp_client.py b/xknx/io/udp_client.py
index 220748ab..54df4fca 100644
--- a/xknx/io/udp_client.py
+++ b/xknx/io/udp_client.py
@@ -5,12 +5,17 @@ The module is build upon asyncio udp functions.
 Due to lame support of UDP multicast within asyncio some special treatment for multicast is necessary.
 """
 import asyncio
+import logging
 import socket
 from sys import platform
 
 from xknx.exceptions import CouldNotParseKNXIP, XKNXException
 from xknx.knxip import KNXIPFrame
 
+raw_socket_logger = logging.getLogger("xknx_raw_socket")
+logger = logging.getLogger("xknx_log")
+knx_logger = logging.getLogger("xknx_knx")
+
 
 class UDPClient:
     """Class for handling (sending and receiving) UDP packets."""
@@ -32,9 +37,8 @@ class UDPClient:
     class UDPClientFactory(asyncio.DatagramProtocol):
         """Abstraction for managing the asyncio-udp transports."""
 
-        def __init__(self, xknx, own_ip, multicast=False, data_received_callback=None):
+        def __init__(self, own_ip, multicast=False, data_received_callback=None):
             """Initialize UDPClientFactory class."""
-            self.xknx = xknx
             self.own_ip = own_ip
             self.multicast = multicast
             self.transport = None
@@ -46,19 +50,19 @@ class UDPClient:
 
         def datagram_received(self, data, addr):
             """Call assigned callback. Callback for datagram received."""
-            self.xknx.raw_socket_logger.debug("Received from %s: %s", addr, data.hex())
+            raw_socket_logger.debug("Received from %s: %s", addr, data.hex())
             if self.data_received_callback is not None:
                 self.data_received_callback(data)
 
         def error_received(self, exc):
             """Handle errors. Callback for error received."""
             if hasattr(self, "xknx"):
-                self.xknx.logger.warning("Error received: %s", exc)
+                logger.warning("Error received: %s", exc)
 
         def connection_lost(self, exc):
             """Log error. Callback for connection lost."""
             if hasattr(self, "xknx") and exc is not None:
-                self.xknx.logger.info("Closing transport.")
+                logger.info("Closing transport.")
 
     def __init__(self, xknx, local_addr, remote_addr, multicast=False):
         """Initialize UDPClient class."""
@@ -80,10 +84,10 @@ class UDPClient:
             try:
                 knxipframe = KNXIPFrame(self.xknx)
                 knxipframe.from_knx(raw)
-                self.xknx.knx_logger.debug("Received: %s", knxipframe)
+                knx_logger.debug("Received: %s", knxipframe)
                 self.handle_knxipframe(knxipframe)
             except CouldNotParseKNXIP as couldnotparseknxip:
-                self.xknx.logger.exception(couldnotparseknxip)
+                logger.exception(couldnotparseknxip)
 
     def handle_knxipframe(self, knxipframe):
         """Handle KNXIP Frame and call all callbacks which watch for the service type ident."""
@@ -93,9 +97,7 @@ class UDPClient:
                 callback.callback(knxipframe, self)
                 handled = True
         if not handled:
-            self.xknx.logger.debug(
-                "UNHANDLED: %s", knxipframe.header.service_type_ident
-            )
+            logger.debug("UNHANDLED: %s", knxipframe.header.service_type_ident)
 
     def register_callback(self, callback, service_types=None):
         """Register callback."""
@@ -148,7 +150,6 @@ class UDPClient:
     async def connect(self):
         """Connect UDP socket. Open UDP port and build mulitcast socket if necessary."""
         udp_client_factory = UDPClient.UDPClientFactory(
-            self.xknx,
             self.local_addr[0],
             multicast=self.multicast,
             data_received_callback=self.data_received_callback,
@@ -171,7 +172,7 @@ class UDPClient:
 
     def send(self, knxipframe):
         """Send KNXIPFrame to socket."""
-        self.xknx.knx_logger.debug("Sending: %s", knxipframe)
+        knx_logger.debug("Sending: %s", knxipframe)
         if self.transport is None:
             raise XKNXException("Transport not connected")
 
diff --git a/xknx/knxip/body.py b/xknx/knxip/body.py
index ee0a25a6..81bafc86 100644
--- a/xknx/knxip/body.py
+++ b/xknx/knxip/body.py
@@ -1,4 +1,7 @@
 """Basis class for all KNX/IP bodies."""
+import logging
+
+logger = logging.getLogger("xknx_log")
 
 
 class KNXIPBody:
@@ -10,22 +13,18 @@ class KNXIPBody:
 
     def calculated_length(self):
         """Get length of KNX/IP body."""
-        self.xknx.logger.warning(
+        logger.warning(
             "'calculated_length()' not implemented for %s", self.__class__.__name__
         )
 
     def from_knx(self, raw):
         """Parse/deserialize from KNX/IP raw data."""
         # pylint: disable=unused-argument
-        self.xknx.logger.warning(
-            "'from_knx()' not implemented for %s", self.__class__.__name__
-        )
+        logger.warning("'from_knx()' not implemented for %s", self.__class__.__name__)
 
     def to_knx(self):
         """Serialize to KNX/IP raw data."""
-        self.xknx.logger.warning(
-            "'to_knx()' not implemented for %s", self.__class__.__name__
-        )
+        logger.warning("'to_knx()' not implemented for %s", self.__class__.__name__)
 
     def __eq__(self, other):
         """Equal operator."""
diff --git a/xknx/knxip/routing_indication.py b/xknx/knxip/routing_indication.py
index d228c11b..2d1d5e15 100644
--- a/xknx/knxip/routing_indication.py
+++ b/xknx/knxip/routing_indication.py
@@ -3,12 +3,16 @@ Module for Serialization and Deserialization of KNX Routing Indications.
 
 Routing indications are used to transport CEMI Messages.
 """
+import logging
+
 from xknx.exceptions import UnsupportedCEMIMessage
 
 from .body import KNXIPBody
 from .cemi_frame import CEMIFrame
 from .knxip_enum import CEMIMessageCode, KNXIPServiceType
 
+logger = logging.getLogger("xknx_log")
+
 
 class RoutingIndication(KNXIPBody):
     """Representation of a KNX Routing Indication."""
@@ -35,7 +39,7 @@ class RoutingIndication(KNXIPBody):
         try:
             return self.cemi.from_knx(raw)
         except UnsupportedCEMIMessage as unsupported_cemi_err:
-            self.xknx.logger.warning("CEMI not supported: %s", unsupported_cemi_err)
+            logger.warning("CEMI not supported: %s", unsupported_cemi_err)
             self.cemi = None
             return len(raw)
 
diff --git a/xknx/knxip/tunnelling_request.py b/xknx/knxip/tunnelling_request.py
index 5811314f..b9b637b0 100644
--- a/xknx/knxip/tunnelling_request.py
+++ b/xknx/knxip/tunnelling_request.py
@@ -3,12 +3,16 @@ Module for Serialization and Deserialization of a KNX Tunnelling Request informa
 
 Tunnelling requests are used to transmit a KNX telegram within an existing KNX tunnel connection.
 """
+import logging
+
 from xknx.exceptions import CouldNotParseKNXIP, UnsupportedCEMIMessage
 
 from .body import KNXIPBody
 from .cemi_frame import CEMIFrame, CEMIMessageCode
 from .knxip_enum import KNXIPServiceType
 
+logger = logging.getLogger("xknx_log")
+
 
 class TunnellingRequest(KNXIPBody):
     """Representation of a KNX Tunnelling Request."""
@@ -58,7 +62,7 @@ class TunnellingRequest(KNXIPBody):
         try:
             pos += self.cemi.from_knx(raw[pos:])
         except UnsupportedCEMIMessage as unsupported_cemi_err:
-            self.xknx.logger.warning("CEMI not supported: %s", unsupported_cemi_err)
+            logger.warning("CEMI not supported: %s", unsupported_cemi_err)
             # Set cemi to None - this is checked in Tunnel() to send Ack even for unsupported CEMI messages.
             self.cemi = None
             pos += len(raw)
diff --git a/xknx/remote_value/remote_value.py b/xknx/remote_value/remote_value.py
index 5c0a6c21..7d822ccf 100644
--- a/xknx/remote_value/remote_value.py
+++ b/xknx/remote_value/remote_value.py
@@ -6,9 +6,13 @@ Remote value can be :
 - a group address for reading a KNX value,
 - or a group of both representing the same value.
 """
+import logging
+
 from xknx.exceptions import CouldNotParseTelegram
 from xknx.telegram import GroupAddress, Telegram, TelegramType
 
+logger = logging.getLogger("xknx_log")
+
 
 class RemoteValue:
     """Class for managing remote knx value."""
@@ -73,7 +77,7 @@ class RemoteValue:
     def payload_valid(self, payload):
         """Test if telegram payload may be parsed - to be implemented in derived class.."""
         # pylint: disable=unused-argument
-        self.xknx.logger.warning(
+        logger.warning(
             "'payload_valid()' not implemented for %s", self.__class__.__name__
         )
         return True
@@ -81,16 +85,12 @@ class RemoteValue:
     def from_knx(self, payload):
         """Convert current payload to value - to be implemented in derived class."""
         # pylint: disable=unused-argument
-        self.xknx.logger.warning(
-            "'from_knx()' not implemented for %s", self.__class__.__name__
-        )
+        logger.warning("'from_knx()' not implemented for %s", self.__class__.__name__)
 
     def to_knx(self, value):
         """Convert value to payload - to be implemented in derived class."""
         # pylint: disable=unused-argument
-        self.xknx.logger.warning(
-            "'to_knx()' not implemented for %s", self.__class__.__name__
-        )
+        logger.warning("'to_knx()' not implemented for %s", self.__class__.__name__)
 
     async def process(self, telegram, always_callback=False):
         """Process incoming telegram."""
@@ -132,7 +132,7 @@ class RemoteValue:
     async def set(self, value, response=False):
         """Set new value."""
         if not self.initialized:
-            self.xknx.logger.info(
+            logger.info(
                 "Setting value of uninitialized device: %s - %s (value: %s)",
                 self.device_name,
                 self.feature_name,
@@ -140,7 +140,7 @@ class RemoteValue:
             )
             return
         if not self.writable:
-            self.xknx.logger.warning(
+            logger.warning(
                 "Attempted to set value for non-writable device: %s - %s (value: %s)",
                 self.device_name,
                 self.feature_name,
@@ -160,7 +160,7 @@ class RemoteValue:
     async def read_state(self, wait_for_result=False):
         """Send GroupValueRead telegram for state address to KNX bus."""
         if self.readable:
-            self.xknx.logger.debug("Sync %s - %s", self.device_name, self.feature_name)
+            logger.debug("Sync %s - %s", self.device_name, self.feature_name)
             # pylint: disable=import-outside-toplevel
             # TODO: send a ReadRequset and start a timeout from here instead of ValueReader
             #       cancel timeout form process(); delete ValueReader
@@ -172,7 +172,7 @@ class RemoteValue:
                 if telegram is not None:
                     await self.process(telegram)
                 else:
-                    self.xknx.logger.warning(
+                    logger.warning(
                         "Could not sync group address '%s' (%s - %s)",
                         self.group_address_state,
                         self.device_name,
diff --git a/xknx/xknx.py b/xknx/xknx.py
index 382f160a..a8535008 100644
--- a/xknx/xknx.py
+++ b/xknx/xknx.py
@@ -1,6 +1,8 @@
 """XKNX is an Asynchronous Python module for reading and writing KNX/IP packets."""
 import asyncio
 import logging
+from logging.handlers import TimedRotatingFileHandler
+import os
 import signal
 from sys import platform
 
@@ -16,6 +18,8 @@ from xknx.telegram import GroupAddressType, PhysicalAddress
 
 from .__version__ import __version__ as VERSION
 
+logger = logging.getLogger("xknx_log")
+
 
 class XKNX:
     """Class for reading and writing KNX/IP packets."""
@@ -36,6 +40,7 @@ class XKNX:
         rate_limit=DEFAULT_RATE_LIMIT,
         multicast_group=DEFAULT_MCAST_GRP,
         multicast_port=DEFAULT_MCAST_PORT,
+        log_directory=None,
     ):
         """Initialize XKNX class."""
         # pylint: disable=too-many-arguments
@@ -52,13 +57,12 @@ class XKNX:
         self.rate_limit = rate_limit
         self.multicast_group = multicast_group
         self.multicast_port = multicast_port
-        self.logger = logging.getLogger("xknx.log")
-        self.knx_logger = logging.getLogger("xknx.knx")
-        self.telegram_logger = logging.getLogger("xknx.telegram")
-        self.raw_socket_logger = logging.getLogger("xknx.raw_socket")
         self.connection_config = None
         self.version = VERSION
 
+        if log_directory is not None:
+            self.setup_logging(log_directory)
+
         if config is not None:
             Config(self).read(config)
 
@@ -75,7 +79,7 @@ class XKNX:
                 task = self.loop.create_task(self.stop())
                 self.loop.run_until_complete(task)
             except RuntimeError as exp:
-                self.logger.warning("Could not close loop, reason: %s", exp)
+                logger.warning("Could not close loop, reason: %s", exp)
 
     async def start(
         self, state_updater=False, daemon_mode=False, connection_config=None
@@ -87,7 +91,7 @@ class XKNX:
             else:
                 connection_config = self.connection_config
         self.knxip_interface = KNXIPInterface(self, connection_config=connection_config)
-        self.logger.info(
+        logger.info(
             "XKNX v%s starting %s connection to KNX bus.",
             VERSION,
             connection_config.connection_type.name.lower(),
@@ -126,8 +130,37 @@ class XKNX:
             self.sigint_received.set()
 
         if platform == "win32":
-            self.logger.warning("Windows does not support signals")
+            logger.warning("Windows does not support signals")
         else:
             self.loop.add_signal_handler(signal.SIGINT, sigint_handler)
-        self.logger.warning("Press Ctrl+C to stop")
+        logger.warning("Press Ctrl+C to stop")
         await self.sigint_received.wait()
+
+    @staticmethod
+    def setup_logging(log_directory: str):
+        """Configure logging to file."""
+        if not os.path.isdir(log_directory):
+            logger.warning("The provided log directory does not exist.")
+            return
+
+        _handler = TimedRotatingFileHandler(
+            filename=f"{log_directory}{os.sep}xknx.log",
+            when="midnight",
+            backupCount=7,
+            encoding="utf-8",
+        )
+        _formatter = logging.Formatter(
+            "%(asctime)s | %(name)s | %(levelname)s | %(message)s",
+            datefmt="%Y-%m-%d %H:%M:%S",
+        )
+        _handler.setFormatter(_formatter)
+        _handler.setLevel(logging.DEBUG)
+
+        for log_namespace in [
+            "xknx_log",
+            "xknx_knx",
+            "xknx_raw_socket",
+            "xknx_telegram",
+        ]:
+            _logger = logging.getLogger(log_namespace)
+            _logger.addHandler(_handler)


Test patch:
diff --git a/test/xknx_test.py b/test/xknx_test.py
new file mode 100644
index 00000000..671a255e
--- /dev/null
+++ b/test/xknx_test.py
@@ -0,0 +1,93 @@
+"""Unit test for XKNX Module."""
+import asyncio
+import os
+import unittest
+from unittest.mock import MagicMock, patch
+
+from xknx import XKNX
+
+# pylint: disable=too-many-public-methods,invalid-name,useless-super-delegation,protected-access
+from xknx.io import ConnectionConfig, ConnectionType
+
+
+class AsyncMock(MagicMock):
+    """Async Mock."""
+
+    # pylint: disable=invalid-overridden-method
+    async def __call__(self, *args, **kwargs):
+        return super().__call__(*args, **kwargs)
+
+
+class TestXknxModule(unittest.TestCase):
+    """Test class for XKNX."""
+
+    def setUp(self):
+        """Set up test class."""
+        self.loop = asyncio.new_event_loop()
+        asyncio.set_event_loop(self.loop)
+
+    def tearDown(self):
+        """Tear down test class."""
+        self.loop.close()
+
+    def test_log_to_file(self):
+        """Test logging enable."""
+        XKNX(loop=self.loop, log_directory="/tmp/")
+
+        self.assertTrue(os.path.isfile("/tmp/xknx.log"))
+
+        os.remove("/tmp/xknx.log")
+
+    def test_log_to_file_when_dir_does_not_exist(self):
+        """Test logging enable with non existent directory."""
+        XKNX(loop=self.loop, log_directory="/xknx/is/fun")
+
+        self.assertFalse(os.path.isfile("/xknx/is/fun/xknx.log"))
+
+    def test_register_telegram_cb(self):
+        """Test register telegram callback."""
+
+        async def telegram_received(telegram):
+            """Telegram received."""
+
+        xknx = XKNX(loop=self.loop, telegram_received_cb=telegram_received)
+
+        self.assertEqual(len(xknx.telegram_queue.telegram_received_cbs), 1)
+
+    def test_register_device_cb(self):
+        """Test register telegram callback."""
+
+        async def device_update(device):
+            """Device updated."""
+
+        xknx = XKNX(loop=self.loop, device_updated_cb=device_update)
+
+        self.assertEqual(len(xknx.devices.device_updated_cbs), 1)
+
+    @patch("xknx.io.KNXIPInterface.start", new_callable=AsyncMock)
+    def test_xknx_start(self, start_mock):
+        """Test xknx start."""
+        xknx = XKNX(loop=self.loop)
+
+        self.loop.run_until_complete(xknx.start(state_updater=True))
+
+        start_mock.assert_called_once()
+        self.loop.run_until_complete(xknx.stop())
+
+    @patch("xknx.io.KNXIPInterface.start", new_callable=AsyncMock)
+    def test_xknx_start_and_stop_with_dedicated_connection_config(self, start_mock):
+        """Test xknx start and stop with connection config."""
+        xknx = XKNX(loop=self.loop)
+
+        connection_config = ConnectionConfig(connection_type=ConnectionType.TUNNELING)
+        xknx.connection_config = connection_config
+
+        self.loop.run_until_complete(xknx.start(state_updater=True))
+
+        start_mock.assert_called_once()
+        self.assertEqual(xknx.knxip_interface.connection_config, connection_config)
+
+        self.loop.run_until_complete(xknx.stop())
+        self.assertIsNone(xknx.knxip_interface)
+        self.assertTrue(xknx.telegram_queue._consumer_task.done())
+        self.assertFalse(xknx.state_updater.started)