From: 
Subject: Debian changes

The Debian packaging of python-crownstone-sse is maintained in git, using a workflow
similar to the one described in dgit-maint-merge(7).
The Debian delta is represented by this one combined patch; there isn't a
patch queue that can be represented as a quilt series.

A detailed breakdown of the changes is available from their canonical
representation -- git commits in the packaging repository.
For example, to see the changes made by the Debian maintainer in the first
upload of upstream version 1.2.3, you could use:

    % git clone https://git.dgit.debian.org/python-crownstone-sse
    % cd python-crownstone-sse
    % git log --oneline 1.2.3..debian/1.2.3-1 -- . ':!debian'

(If you have dgit, use `dgit clone python-crownstone-sse`, rather than plain `git clone`.)

We don't use debian/source/options single-debian-patch because it has bugs.
Therefore, NMUs etc. may nevertheless have made additional patches.

---

diff --git a/setup.py b/setup.py
index 928cec3..01bc47b 100644
--- a/setup.py
+++ b/setup.py
@@ -10,7 +10,7 @@ setup(
     author='Crownstone B.V.',
     long_description=long_description,
     long_description_content_type="text/markdown",
-    packages=find_packages(exclude=['examples', 'tests']),
+    packages=find_packages(exclude=['examples', 'examples.*', 'tests', 'tests.*']),
     install_requires=list(package.strip() for package in open('requirements.txt')),
     classifiers=[
         'Programming Language :: Python :: 3.8',
diff --git a/tests/test_async_client.py b/tests/test_async_client.py
new file mode 100644
index 0000000..bad343e
--- /dev/null
+++ b/tests/test_async_client.py
@@ -0,0 +1,91 @@
+"""Exercise the async client with mocked HTTP responses and no cloud access."""
+
+import hashlib
+import unittest
+from unittest.mock import AsyncMock, Mock
+
+import aiohttp
+
+from crownstone_sse.async_client import CrownstoneSSEAsync
+from crownstone_sse.events import PingEvent
+from crownstone_sse.exceptions import (
+    AuthError,
+    ConnectError,
+    CrownstoneAuthException,
+    CrownstoneConnectionException,
+)
+from tests.mocked_replies.login_errors import auth_error, not_verified
+from tests.mocked_replies.login_data import login_data
+
+
+async def stream_lines():
+    for line in (b": keepalive\r\n", b"\r\n", b'data: {"type":"ping","counter":2}\r\n'):
+        yield line
+
+
+class AsyncClientTests(unittest.IsolatedAsyncioTestCase):
+    def setUp(self):
+        self.session = Mock()
+        self.session.post = AsyncMock()
+        self.session.get = AsyncMock()
+        self.client = CrownstoneSSEAsync(
+            "test@example.invalid", "password", websession=self.session
+        )
+
+    def login_response(self, status, data):
+        response = Mock(status=status)
+        response.json = AsyncMock(return_value=data)
+        self.session.post.return_value = response
+
+    async def test_login(self):
+        self.login_response(200, login_data)
+        await self.client._async_login()
+        self.assertEqual(self.client._access_token, "my_access_token")
+        self.assertEqual(self.session.post.call_args.kwargs["json"], {
+            "email": "test@example.invalid",
+            "password": hashlib.sha1(b"password").hexdigest(),
+        })
+
+    async def test_login_errors(self):
+        for status, data, expected in (
+            (401, auth_error, AuthError.AUTHENTICATION_ERROR),
+            (401, not_verified, AuthError.EMAIL_NOT_VERIFIED),
+            (500, {}, AuthError.UNKNOWN_ERROR),
+        ):
+            with self.subTest(status=status, error=expected):
+                self.login_response(status, data)
+                with self.assertRaises(CrownstoneAuthException) as raised:
+                    await self.client._async_login()
+                self.assertEqual(raised.exception.type, expected)
+
+    async def test_login_connection_error(self):
+        self.session.post.side_effect = aiohttp.ClientConnectionError()
+        with self.assertRaises(CrownstoneConnectionException) as raised:
+            await self.client._async_login()
+        self.assertEqual(raised.exception.type, ConnectError.CONNECTION_FAILED_NO_INTERNET)
+
+    async def test_stream_and_external_session_lifecycle(self):
+        response = Mock(status=200)
+        response.content = stream_lines()
+        self.session.get.return_value = response
+        self.client._access_token = "existing-token"
+        async with self.client as client:
+            self.session.post.assert_not_called()
+            response.raise_for_status.assert_called_once_with()
+            self.assertTrue(client.is_available)
+            event = await anext(client)
+            self.assertIsInstance(event, PingEvent)
+            self.assertEqual(event.counter, 2)
+        response.close.assert_called_once_with()
+        self.session.close.assert_not_called()
+        self.assertFalse(self.client.is_available)
+
+    async def test_no_response(self):
+        with self.assertRaises(CrownstoneConnectionException) as raised:
+            await anext(self.client)
+        self.assertEqual(raised.exception.type, ConnectError.CONNECTION_NO_RESPONSE)
+
+    async def test_http_204_ends_stream(self):
+        self.client._client_response = Mock(status=204)
+        with self.assertRaises(StopAsyncIteration):
+            await anext(self.client)
diff --git a/tests/test_events.py b/tests/test_events.py
new file mode 100644
index 0000000..de96ca3
--- /dev/null
+++ b/tests/test_events.py
@@ -0,0 +1,118 @@
+"""Offline tests for event decoding and delivery."""
+
+import asyncio
+import json
+import unittest
+
+from crownstone_sse.events import (
+    AbilityChangeEvent,
+    DataChangeEvent,
+    MultiSwitchCommandEvent,
+    PingEvent,
+    PresenceEvent,
+    SwitchStateUpdateEvent,
+    SystemEvent,
+    parse_event,
+)
+from crownstone_sse.helpers.switch_command import SwitchCommand, SwitchCommandType
+from crownstone_sse.util.eventbus import EventBus
+from tests.mocked_events.presence_events import enter_location
+from tests.mocked_events.system_events import stream_start
+
+
+class EventTests(unittest.TestCase):
+    def test_event_types(self):
+        for event_type, event_class in (
+            ("abilityChange", AbilityChangeEvent),
+            ("dataChange", DataChangeEvent),
+            ("command", MultiSwitchCommandEvent),
+            ("ping", PingEvent),
+            ("presence", PresenceEvent),
+            ("switchStateUpdate", SwitchStateUpdateEvent),
+            ("system", SystemEvent),
+        ):
+            with self.subTest(event_type=event_type):
+                data = {"type": event_type}
+                event = parse_event(data)
+                self.assertIsInstance(event, event_class)
+                self.assertEqual(event.type, event_type)
+                self.assertEqual(json.loads(str(event)), data)
+
+    def test_unknown_event(self):
+        self.assertIsNone(parse_event({"type": "unknown"}))
+
+    def test_ping(self):
+        event = parse_event({"type": "ping", "counter": 3})
+        self.assertEqual(event.counter, 3)
+        self.assertEqual(event.elapsed_time, 90)
+
+    def test_presence(self):
+        event = parse_event(enter_location)
+        self.assertEqual(event.sub_type, "enterLocation")
+        self.assertEqual(event.sphere_id, "sphere_id")
+        self.assertEqual(event.location_id, "location_id")
+        self.assertEqual(event.user_id, "user_id")
+
+    def test_system(self):
+        event = parse_event(stream_start)
+        self.assertEqual(event.sub_type, "STREAM_START")
+        self.assertEqual(event.code, 200)
+        self.assertEqual(event.message, "Stream Starting.")
+
+    def test_switch_commands(self):
+        for command_type, expected in (
+            ("TURN_ON", SwitchCommandType.TURN_ON),
+            ("TURN_OFF", SwitchCommandType.TURN_OFF),
+            ("unknown", SwitchCommandType.UNKNOWN),
+        ):
+            with self.subTest(command_type=command_type):
+                command = SwitchCommand({"type": command_type, "id": "stone", "uid": 1})
+                self.assertEqual(command.cloud_id, "stone")
+                self.assertEqual(command.unique_id, "1")
+                self.assertEqual(command.switch_val.type, expected)
+
+    def test_switch_percentage_limits(self):
+        for percentage, expected in ((-10, 0), (0, 0), (42, 42), (100, 100), (110, 100)):
+            with self.subTest(percentage=percentage):
+                command = SwitchCommand({"type": "PERCENTAGE", "percentage": percentage})
+                self.assertEqual(command.switch_val.type, SwitchCommandType.PERCENTAGE)
+                self.assertEqual(command.switch_val.percentage, expected)
+
+    def test_multiple_switch_commands(self):
+        event = parse_event({
+            "type": "command",
+            "switchData": [{"type": "TURN_ON"}, {"type": "TURN_OFF"}],
+        })
+        self.assertEqual(
+            [command.switch_val.type for command in event.crownstone_list],
+            [SwitchCommandType.TURN_ON, SwitchCommandType.TURN_OFF],
+        )
+
+    def test_event_listener_lifecycle(self):
+        bus = EventBus()
+        received = []
+        remove = bus.add_event_listener("ping", received.append)
+        event = PingEvent({"type": "ping", "counter": 1})
+        self.assertEqual(bus.get_event_listeners(), {"ping": 1})
+        bus.fire("presence", event)
+        self.assertEqual(received, [])
+        bus.fire("ping", event)
+        self.assertEqual(received, [event])
+        remove()
+        self.assertEqual(bus.get_event_listeners(), {})
+        bus.fire("ping", event)
+        self.assertEqual(received, [event])
+
+
+class AsyncEventBusTests(unittest.IsolatedAsyncioTestCase):
+    async def test_async_listener(self):
+        bus = EventBus()
+        received = asyncio.get_running_loop().create_future()
+
+        async def listener(event):
+            received.set_result(event)
+
+        bus.add_event_listener("ping", listener)
+        event = PingEvent({"type": "ping", "counter": 1})
+        bus.fire("ping", event)
+        self.assertIs(await asyncio.wait_for(received, timeout=2), event)
