Repository: XKNX/xknx
Pull request #431 (2020-09-26 18:23:05)

Introduce async context manager
Drop support for python 3.6

Move arguments from XKNX.start() to XKNX()

Allows something like this:

```python

async def main():
    """Connect to KNX/IP and read the state of a Climate device."""
    async with XKNX() as xknx:
        climate = Climate(xknx, "TestClimate", group_address_temperature="6/2/1")
        await climate.sync(wait_for_result=True)
        # Will print out state of climate including current temperature:
        print(climate)


asyncio.run(main())
```

- Only call coveralls once in CI

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

Coverage increased (+0.1%) to 93.41% when pulling **4d08a16f45676d4b8b66e59f394a0adb3749bbaa on marvin-w:feature/introduce_async_context_manager** into **851d1c1239a59c7f06058656d3ee861ae9fe1630 on XKNX:master**.


Patch:
diff --git a/.travis.yml b/.travis.yml
index 8fbd6f49..1031d4e4 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -8,15 +8,10 @@ jobs:
       env: TOXENV=pylint
     - python: "3.8"
       env: TOXENV=lint
-    - python: "3.6"
-      env: TOXENV=py36
-      after_success: coveralls
     - python: "3.7"
       env: TOXENV=py37
-      after_success: coveralls
     - python: "3.8"
       env: TOXENV=py38
-      after_success: coveralls
     - python: "3.8.5"
       env: TOXENV=py38
       after_success: coveralls
diff --git a/README.md b/README.md
index 618f3175..f6936673 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ We need your help for testing and improving XKNX. For questions, feature request
 Development
 -----------
 
-Requirements: Python > 3.5
+You will need at least Python 3.7 in order to use XKNX.
 
 Setting up your local environment:
 
@@ -44,19 +44,13 @@ from xknx.devices import Light
 
 async def main():
     """Connect to KNX/IP bus, switch on light, wait 2 seconds and switch it off again."""
-    xknx = XKNX()
-    await xknx.start()
-    light = Light(xknx,
-                  name='TestLight',
-                  group_address_switch='1/0/9')
-    await light.set_on()
-    await asyncio.sleep(2)
-    await light.set_off()
-    await xknx.stop()
-
-
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+    async with XKNX() as xknx:
+        light = Light(xknx,
+                      name='TestLight',
+                      group_address_switch='1/0/9')
+        await light.set_on()
+        await asyncio.sleep(2)
+        await light.set_off()
+
+asyncio.run(main())
 ```
diff --git a/changelog.md b/changelog.md
index a2a07894..6dd3720b 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,19 @@
 # Changelog
 
+## Unreleased
+
+### Logging
+
+- An additional `log_directory` parameter has been introduced that allows you to log your KNX logs to a dedicated file. We will likely silence more logs over the time but this option will help you and us to triage issues easier in the future. It is disabled by default.
+
+### Internals
+
+- The heartbeat task, that is used to monitor the state of the tunnel and trigger reconnects if it doesn't respond, is now properly stopped once we receive the first reconnect request
+- `XKNX.start()` no longer takes arguments. They are now passed directly to the constructor when instantiating `XKNX()`
+- Support for python 3.6 has been dropped
+- XKNX can now be used as an asynchronous context manager
+- Internal refactorings
+
 ## 0.14.4 Bugfix release
 
 ### Devices
diff --git a/docs/xknx.md b/docs/xknx.md
index d3387218..20bbc4c1 100644
--- a/docs/xknx.md
+++ b/docs/xknx.md
@@ -28,7 +28,10 @@ xknx = XKNX(config='xknx.yaml',
             rate_limit=DEFAULT_RATE_LIMIT,
             multicast_group=DEFAULT_MCAST_GRP,
             multicast_port=DEFAULT_MCAST_PORT,
-            log_directory=None)
+            log_directory=None,
+            state_updater=False,
+            daemon_mode=False,
+            connection_config=ConnectionConfig())
 ```
 
 The constructor of the XKNX object takes several parameters:
@@ -46,20 +49,17 @@ The constructor of the XKNX object takes several parameters:
 * `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.
+* if `state_updater` is set, XKNX will start (once `start() is called) an asynchronous process for syncing the states of all connected devices every hour
+* if `daemon_mode` is set, start will only stop if Control-X is pressed. This function is useful for using XKNX as a daemon, e.g. for using the callback functions or using the internal action logic.
+* `connection_config` replaces a ConnectionConfig() that was read from a yaml config file.
 
 # [](#header-2)Starting
 
 ```python
-await xknx.start(state_updater=False,
-                 daemon_mode=False,
-                 connection_config=None)
+await xknx.start()
 ```
 
-`xknx.start()` will search for KNX/IP devices in the network and either build a KNX/IP-Tunnel or open a mulitcast KNX/IP-Routing connection. `start()` will take the following paramters
-
-* if `state_updater` is set, XKNX will start an asynchronous process for syncing the states of all connected devices every hour
-* if `daemon_mode` is set, start will only stop if Control-X is pressed. This function is useful for using XKNX as a daemon, e.g. for using the callback functions or using the internal action logic.
-* `connection_config` replaces a ConnectionConfig() that was read from a yaml config file.
+`xknx.start()` will search for KNX/IP devices in the network and either build a KNX/IP-Tunnel or open a mulitcast KNX/IP-Routing connection. `start()` will not take any parameters.
 
 # [](#header-2)Stopping
 
@@ -69,6 +69,42 @@ await xknx.stop()
 
 Will disconnect from tunneling devices and stop the different queues.
 
+# [](#header-2)Using XKNX as an asynchronous context manager
+
+Except of writing:
+
+```python
+import asyncio
+
+async def main():
+    xknx = XKNX()
+    await xknx.start()
+    switch = Switch(xknx,
+                name='TestSwitch',
+                group_address='1/1/11')
+
+    await switch.set_on()
+    await xknx.stop()
+
+asyncio.run(main())
+```
+
+you can now use the following syntax:
+
+```python
+import asyncio
+
+async def main():
+    async with XKNX() as xknx:
+        switch = Switch(xknx,
+                name='TestSwitch',
+                group_address='1/1/11')
+
+        await switch.set_on()
+
+asyncio.run(main())
+```
+
 # [](#header-2)Devices
 
 The XKNX may keep all devices in a local storage named `devices`. All devices may be accessed by their name: `xknx.devices['NameOfDevice']`. If XKNX receives an update via KNX GROUP WRITE the device is updated automatically.
diff --git a/examples/example_climate.py b/examples/example_climate.py
index 985cb5a9..a6e0e547 100644
--- a/examples/example_climate.py
+++ b/examples/example_climate.py
@@ -8,18 +8,11 @@ from xknx.devices import Climate
 async def main():
     """Connect to KNX/IP and read the state of a Climate device."""
     xknx = XKNX()
-    await xknx.start()
+    async with xknx:
+        climate = Climate(xknx, "TestClimate", group_address_temperature="6/2/1")
+        await climate.sync(wait_for_result=True)
+        # Will print out state of climate including current temperature:
+        print(climate)
 
-    climate = Climate(xknx, "TestClimate", group_address_temperature="6/2/1")
-    await climate.sync()
 
-    # Will print out state of climate including current temperature:
-    print(climate)
-
-    await xknx.stop()
-
-
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+asyncio.run(main())
diff --git a/examples/example_config.py b/examples/example_config.py
index efb97c2c..ba08aa72 100644
--- a/examples/example_config.py
+++ b/examples/example_config.py
@@ -12,7 +12,4 @@ async def main():
         print(device)
 
 
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+asyncio.run(main())
diff --git a/examples/example_daemon.py b/examples/example_daemon.py
index 2537f87d..e3d4ef1d 100644
--- a/examples/example_daemon.py
+++ b/examples/example_daemon.py
@@ -12,16 +12,13 @@ async def device_updated_cb(device):
 
 async def main():
     """Connect to KNX/IP device and listen if a switch was updated via KNX bus."""
-    xknx = XKNX(device_updated_cb=device_updated_cb)
+    xknx = XKNX(device_updated_cb=device_updated_cb, daemon_mode=True)
     Switch(xknx, name="TestOutlet", group_address="1/1/11")
 
     # Wait until Ctrl-C was pressed
-    await xknx.start(daemon_mode=True)
+    await xknx.start()
 
     await xknx.stop()
 
 
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+asyncio.run(main())
diff --git a/examples/example_datetime.py b/examples/example_datetime.py
index 2e8ffad3..a5ddba2d 100644
--- a/examples/example_datetime.py
+++ b/examples/example_datetime.py
@@ -7,14 +7,11 @@ from xknx.devices import DateTime
 
 async def main():
     """Connect to KNX/IP device and broadcast time."""
-    xknx = XKNX()
+    xknx = XKNX(daemon_mode=True)
     DateTime(xknx, "TimeTest", group_address="1/2/3", broadcast_type="time")
     print("Sending time to KNX bus every hour")
-    await xknx.start(daemon_mode=True)
+    await xknx.start()
     await xknx.stop()
 
 
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+asyncio.run(main())
diff --git a/examples/example_devices.py b/examples/example_devices.py
index 9394501b..9905a6c4 100644
--- a/examples/example_devices.py
+++ b/examples/example_devices.py
@@ -17,7 +17,4 @@ async def main():
     await xknx.stop()
 
 
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+asyncio.run(main())
diff --git a/examples/example_disconnect.py b/examples/example_disconnect.py
index f77bae2d..71dccf09 100644
--- a/examples/example_disconnect.py
+++ b/examples/example_disconnect.py
@@ -41,7 +41,4 @@ async def main():
                 print("Disconnected ", i)
 
 
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+asyncio.run(main())
diff --git a/examples/example_gatewayscanner.py b/examples/example_gatewayscanner.py
index bc21c5c7..f3c37110 100644
--- a/examples/example_gatewayscanner.py
+++ b/examples/example_gatewayscanner.py
@@ -31,7 +31,4 @@ async def main():
                 )
 
 
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+asyncio.run(main())
diff --git a/examples/example_light_dimm.py b/examples/example_light_dimm.py
index 8d65f3eb..30755032 100644
--- a/examples/example_light_dimm.py
+++ b/examples/example_light_dimm.py
@@ -26,7 +26,4 @@ async def main():
     await xknx.stop()
 
 
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+asyncio.run(main())
diff --git a/examples/example_light_rgbw.py b/examples/example_light_rgbw.py
index b2973cc0..ff2638b1 100644
--- a/examples/example_light_rgbw.py
+++ b/examples/example_light_rgbw.py
@@ -45,7 +45,4 @@ async def main():
     await xknx.stop()
 
 
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+asyncio.run(main())
diff --git a/examples/example_light_state.py b/examples/example_light_state.py
index 96fc31de..bd7d390e 100644
--- a/examples/example_light_state.py
+++ b/examples/example_light_state.py
@@ -26,7 +26,4 @@ async def main():
     await xknx.stop()
 
 
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+asyncio.run(main())
diff --git a/examples/example_light_switch.py b/examples/example_light_switch.py
index 9dd3a73b..18f825c7 100644
--- a/examples/example_light_switch.py
+++ b/examples/example_light_switch.py
@@ -16,7 +16,4 @@ async def main():
     await xknx.stop()
 
 
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+asyncio.run(main())
diff --git a/examples/example_powermeter_mqtt.py b/examples/example_powermeter_mqtt.py
index 379c85ec..d8065651 100644
--- a/examples/example_powermeter_mqtt.py
+++ b/examples/example_powermeter_mqtt.py
@@ -147,7 +147,7 @@ async def main():
     global mqttc
 
     # Connect to KNX/IP device and listen if a switch was updated via KNX bus.
-    xknx = XKNX(device_updated_cb=device_updated_cb)
+    xknx = XKNX(device_updated_cb=device_updated_cb, daemon_mode=True)
 
     # The KNX addresses to monitor are defined below, but is normally placed in an external
     #  file that is loaded in on start.
@@ -235,14 +235,11 @@ async def main():
     mqttc.loop_start()
 
     # Wait until Ctrl-C was pressed
-    await xknx.start(daemon_mode=True)
+    await xknx.start()
 
     await xknx.stop()
     await mqttc.loop_stop()
     await mqttc.disconnect()
 
 
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+asyncio.run(main())
diff --git a/examples/example_scene.py b/examples/example_scene.py
index 2b128c98..0ee7fa4f 100644
--- a/examples/example_scene.py
+++ b/examples/example_scene.py
@@ -14,7 +14,4 @@ async def main():
     await xknx.stop()
 
 
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+asyncio.run(main())
diff --git a/examples/example_sensor.py b/examples/example_sensor.py
index d4016de3..e31a4c74 100644
--- a/examples/example_sensor.py
+++ b/examples/example_sensor.py
@@ -32,7 +32,4 @@ async def main():
     await xknx.stop()
 
 
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+asyncio.run(main())
diff --git a/examples/example_switch.py b/examples/example_switch.py
index d75b3ef6..8c9a28e7 100644
--- a/examples/example_switch.py
+++ b/examples/example_switch.py
@@ -16,7 +16,4 @@ async def main():
     await xknx.stop()
 
 
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+asyncio.run(main())
diff --git a/examples/example_telegram_monitor.py b/examples/example_telegram_monitor.py
index b537f335..b4399fb9 100644
--- a/examples/example_telegram_monitor.py
+++ b/examples/example_telegram_monitor.py
@@ -29,11 +29,11 @@ def show_help():
 
 async def monitor(address_filters):
     """Set telegram_received_cb within XKNX and connect to KNX/IP device in daemon mode."""
-    xknx = XKNX()
+    xknx = XKNX(daemon_mode=True)
     xknx.telegram_queue.register_telegram_received_cb(
         telegram_received_cb, address_filters
     )
-    await xknx.start(daemon_mode=True)
+    await xknx.start()
     await xknx.stop()
 
 
@@ -55,7 +55,4 @@ async def main(argv):
 
 
 if __name__ == "__main__":
-    # pylint: disable=invalid-name
-    loop = asyncio.get_event_loop()
-    loop.run_until_complete(main(sys.argv[1:]))
-    loop.close()
+    asyncio.run(main(sys.argv[1:]))
diff --git a/examples/example_tunnel.py b/examples/example_tunnel.py
index 4a7f7146..090df9db 100644
--- a/examples/example_tunnel.py
+++ b/examples/example_tunnel.py
@@ -46,7 +46,4 @@ async def main():
     await tunnel.disconnect()
 
 
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+asyncio.run(main())
diff --git a/examples/example_value_reader.py b/examples/example_value_reader.py
index e0f65b7f..246d3012 100644
--- a/examples/example_value_reader.py
+++ b/examples/example_value_reader.py
@@ -24,7 +24,4 @@ async def main():
     await xknx.stop()
 
 
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+asyncio.run(main())
diff --git a/examples/example_weather.py b/examples/example_weather.py
index 26ce2284..832dbbb0 100644
--- a/examples/example_weather.py
+++ b/examples/example_weather.py
@@ -4,7 +4,6 @@ import logging
 
 from xknx import XKNX
 from xknx.devices import Weather
-from xknx.io import ConnectionConfig, ConnectionType
 
 logging.basicConfig(level=logging.WARN)
 
@@ -12,13 +11,7 @@ logging.basicConfig(level=logging.WARN)
 async def main():
     """Connect to KNX/IP device and create a weather device and read its sensors."""
     xknx = XKNX()
-    await xknx.start(
-        connection_config=ConnectionConfig(
-            connection_type=ConnectionType.TUNNELING,
-            local_ip="192.168.0.50",
-            gateway_ip="192.168.0.100",
-        )
-    )
+    await xknx.start()
 
     weather = Weather(
         xknx,
@@ -40,7 +33,4 @@ async def main():
     await xknx.stop()
 
 
-# pylint: disable=invalid-name
-loop = asyncio.get_event_loop()
-loop.run_until_complete(main())
-loop.close()
+asyncio.run(main())
diff --git a/home-assistant-plugin/custom_components/xknx/__init__.py b/home-assistant-plugin/custom_components/xknx/__init__.py
index 7e0ba007..2ec42b06 100644
--- a/home-assistant-plugin/custom_components/xknx/__init__.py
+++ b/home-assistant-plugin/custom_components/xknx/__init__.py
@@ -196,15 +196,13 @@ class KNXModule:
             rate_limit=self.config[DOMAIN][CONF_XKNX_RATE_LIMIT],
             multicast_group=self.config[DOMAIN][CONF_XKNX_MCAST_GRP],
             multicast_port=self.config[DOMAIN][CONF_XKNX_MCAST_PORT],
+            connection_config=self.connection_config(),
+            state_updater=self.config[DOMAIN][CONF_XKNX_STATE_UPDATER],
         )
 
     async def start(self):
         """Start KNX object. Connect to tunneling or Routing device."""
-        connection_config = self.connection_config()
-        await self.xknx.start(
-            state_updater=self.config[DOMAIN][CONF_XKNX_STATE_UPDATER],
-            connection_config=connection_config,
-        )
+        await self.xknx.start()
         self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop)
         self.connected = True
 
diff --git a/xknx/xknx.py b/xknx/xknx.py
index a8535008..7a2ae8e7 100644
--- a/xknx/xknx.py
+++ b/xknx/xknx.py
@@ -41,6 +41,9 @@ class XKNX:
         multicast_group=DEFAULT_MCAST_GRP,
         multicast_port=DEFAULT_MCAST_PORT,
         log_directory=None,
+        state_updater=False,
+        daemon_mode=False,
+        connection_config=ConnectionConfig(),
     ):
         """Initialize XKNX class."""
         # pylint: disable=too-many-arguments
@@ -57,7 +60,9 @@ class XKNX:
         self.rate_limit = rate_limit
         self.multicast_group = multicast_group
         self.multicast_port = multicast_port
-        self.connection_config = None
+        self.connection_config = connection_config
+        self.start_state_updater = state_updater
+        self.daemon_mode = daemon_mode
         self.version = VERSION
 
         if log_directory is not None:
@@ -81,27 +86,31 @@ class XKNX:
             except RuntimeError as exp:
                 logger.warning("Could not close loop, reason: %s", exp)
 
-    async def start(
-        self, state_updater=False, daemon_mode=False, connection_config=None
-    ):
+    async def __aenter__(self):
+        """Start XKNX from context manager."""
+        await self.start()
+        return self
+
+    async def __aexit__(self, exc_type, exc, traceback):
+        """Stop XKNX from context manager."""
+        await self.stop()
+
+    async def start(self):
         """Start XKNX module. Connect to KNX/IP devices and start state updater."""
-        if connection_config is None:
-            if self.connection_config is None:
-                connection_config = ConnectionConfig()
-            else:
-                connection_config = self.connection_config
-        self.knxip_interface = KNXIPInterface(self, connection_config=connection_config)
+        self.knxip_interface = KNXIPInterface(
+            self, connection_config=self.connection_config
+        )
         logger.info(
             "XKNX v%s starting %s connection to KNX bus.",
             VERSION,
-            connection_config.connection_type.name.lower(),
+            self.connection_config.connection_type.name.lower(),
         )
         await self.knxip_interface.start()
         await self.telegram_queue.start()
-        if state_updater:
+        if self.start_state_updater:
             self.state_updater.start()
         self.started.set()
-        if daemon_mode:
+        if self.daemon_mode:
             await self.loop_until_sigint()
 
     async def join(self):


Test patch:
diff --git a/test/xknx_test.py b/test/xknx_test.py
index 671a255e..f265200d 100644
--- a/test/xknx_test.py
+++ b/test/xknx_test.py
@@ -67,13 +67,24 @@ class TestXknxModule(unittest.TestCase):
     @patch("xknx.io.KNXIPInterface.start", new_callable=AsyncMock)
     def test_xknx_start(self, start_mock):
         """Test xknx start."""
-        xknx = XKNX(loop=self.loop)
+        xknx = XKNX(loop=self.loop, state_updater=True)
 
-        self.loop.run_until_complete(xknx.start(state_updater=True))
+        self.loop.run_until_complete(xknx.start())
 
         start_mock.assert_called_once()
         self.loop.run_until_complete(xknx.stop())
 
+    @patch("xknx.io.KNXIPInterface.start", new_callable=AsyncMock)
+    def test_xknx_start_as_context_manager(self, ipinterface_mock):
+        """Test xknx start."""
+
+        async def run_in_contextmanager():
+            async with XKNX(loop=self.loop, state_updater=True) as xknx:
+                self.assertTrue(xknx.started.is_set())
+                ipinterface_mock.assert_called_once()
+
+        self.loop.run_until_complete(run_in_contextmanager())
+
     @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."""
@@ -82,7 +93,7 @@ class TestXknxModule(unittest.TestCase):
         connection_config = ConnectionConfig(connection_type=ConnectionType.TUNNELING)
         xknx.connection_config = connection_config
 
-        self.loop.run_until_complete(xknx.start(state_updater=True))
+        self.loop.run_until_complete(xknx.start())
 
         start_mock.assert_called_once()
         self.assertEqual(xknx.knxip_interface.connection_config, connection_config)