diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index 904cb2f..3a8d64a 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -1,49 +1,36 @@
-# This workflow will install Python dependencies, run tests and lint with a single version of Python
-# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
-
 name: Python application
 
 on:
   push:
-    branches: [ "master" ]
+    branches: ["master"]
   pull_request:
-    branches: [ "master" ]
+    branches: ["master"]
 
 permissions:
   contents: read
 
 jobs:
   build:
-
     runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ["3.10", "3.11", "3.12"]
 
     steps:
-    - uses: actions/checkout@v3
-    - name: Set up Python 3.10
-      uses: actions/setup-python@v3
+    - uses: actions/checkout@v4
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v5
       with:
-        python-version: "3.10"
+        python-version: ${{ matrix.python-version }}
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
-        pip install flake8 pytest
-        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
-    - name: Lint with flake8
-      run: |
-        # stop the build if there are Python syntax errors or undefined names
-        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
-        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
-        flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
-    - name: Install pypa/build
-      run: >-
-        python -m
-        pip install
-        build
-        --user
-    - name: Build a binary wheel and a source tarball
+        pip install -e ".[dev]"
+    - name: Lint with ruff
+      run: ruff check .
+    - name: Type check with mypy
+      run: mypy aiopulse
+    - name: Test with pytest
+      run: pytest --cov=aiopulse --cov-report=xml
+    - name: Build package
       run: python -m build
-    - name: Store the distribution packages
-      uses: actions/upload-artifact@v3
-      with:
-        name: python-package-distributions
-        path: dist/
diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml
new file mode 100644
index 0000000..1015fc8
--- /dev/null
+++ b/.github/workflows/python-release.yml
@@ -0,0 +1,38 @@
+name: Release to PyPI
+
+on:
+  push:
+    tags:
+      - "v*"
+
+permissions:
+  contents: write
+  id-token: write
+
+jobs:
+  release:
+    runs-on: ubuntu-latest
+    environment:
+      name: pypi
+      url: https://pypi.org/p/aiopulse
+
+    steps:
+    - uses: actions/checkout@v4
+    - name: Set up Python 3.10
+      uses: actions/setup-python@v5
+      with:
+        python-version: "3.10"
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install -e ".[dev]"
+    - name: Run tests
+      run: pytest
+    - name: Build package
+      run: python -m build
+    - name: Create GitHub Release
+      uses: softprops/action-gh-release@v1
+      with:
+        generate_release_notes: true
+    - name: Publish to PyPI
+      uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 4df6b6e..af7d0d1 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,14 +1,26 @@
 repos:
   - repo: https://github.com/psf/black
-    rev: 19.10b0
+    rev: 24.4.2
     hooks:
       - id: black
         args:
           - --safe
           - --quiet
         files: ^((aiopulse)/.+)?[^/]+\.py$
+  - repo: https://github.com/astral-sh/ruff-pre-commit
+    rev: v0.4.4
+    hooks:
+      - id: ruff
+        args:
+          - --fix
+  - repo: https://github.com/pre-commit/mirrors-mypy
+    rev: v1.10.0
+    hooks:
+      - id: mypy
+        additional_dependencies: [types-all]
+        files: ^aiopulse/.+\.py$
   - repo: https://github.com/codespell-project/codespell
-    rev: v1.16.0
+    rev: v2.2.6
     hooks:
       - id: codespell
         args:
@@ -16,14 +28,3 @@ repos:
           - --skip="./.*,*.json"
           - --quiet-level=2
         exclude_types: [json]
-  - repo: https://gitlab.com/pycqa/flake8
-    rev: 3.7.9
-    hooks:
-      - id: flake8
-        additional_dependencies:
-          - flake8-docstrings==1.5.0
-          - pydocstyle==5.0.2
-        files: ^((aiopulse)/.+)?[^/]+\.py$
-        args:
-          - --max-line-length=88
-          - --ignore=D401,E203,W503
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..0afe57d
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,34 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [0.5.0] - 2026-08-01
+
+### Changed
+- Minimum Python version is now 3.10
+- Modernized asyncio patterns (removed loop parameter deprecation)
+- Updated type hints to Python 3.10+ syntax
+- Improved error handling with bounds checking
+- Extracted CallbackMixin for shared callback logic
+- Updated CI/CD with testing and type checking
+- Organized protocol constants with enums
+
+### Deprecated
+- `loop` parameter in Hub.__init__() and Hub.discover() - will be removed in v0.6.0
+
+### Fixed
+- Duplicate InvalidResponseException catch in hub.py
+- Unsafe lock management in send_command()
+- Missing bounds checking in response parsers
+
+### Added
+- CallbackMixin for shared callback logic
+- HubEntity base class for hub entities
+- Type annotations throughout codebase
+- mypy type checking support
+- GitHub Actions release workflow
+
+## [0.4.7] - Previous release
diff --git a/aiopulse/__init__.py b/aiopulse/__init__.py
index 8876b3b..9cb5982 100644
--- a/aiopulse/__init__.py
+++ b/aiopulse/__init__.py
@@ -26,7 +26,7 @@ __all__ = [
     "InvalidResponseException",
     "UpdateType",
 ]
-__version__ = "0.4.6"
+__version__ = "0.5.0"
 __author__ = "Alan Murray"
 
 _LOGGER = logging.getLogger(__name__)
diff --git a/aiopulse/callbacks.py b/aiopulse/callbacks.py
new file mode 100644
index 0000000..72ad942
--- /dev/null
+++ b/aiopulse/callbacks.py
@@ -0,0 +1,57 @@
+"""Callback mixin for hub entities."""
+from __future__ import annotations
+
+import asyncio
+import functools
+import logging
+from collections.abc import Callable
+from typing import Any
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class CallbackMixin:
+    """Mixin for entities that support update callbacks."""
+
+    def __init__(self) -> None:
+        """Initialize callback list."""
+        self._update_callbacks: list[Callable[..., None]] = []
+
+    def callback_subscribe(self, callback: Callable[..., None]) -> None:
+        """Add a callback for updates."""
+        self._update_callbacks.append(callback)
+
+    def callback_unsubscribe(self, callback: Callable[..., None]) -> None:
+        """Remove a callback for updates."""
+        if callback in self._update_callbacks:
+            self._update_callbacks.remove(callback)
+
+    def notify_callback(self, *args: Any) -> None:
+        """Notify all callbacks of an update."""
+        for callback in self._update_callbacks:
+            self._schedule_callback(callback, *args)
+
+    def _schedule_callback(
+        self, target: Callable[..., Any], *args: Any
+    ) -> asyncio.Task | None:
+        """Schedule a callback to run asynchronously.
+
+        Must be called from within the event loop.
+        """
+        try:
+            loop = asyncio.get_running_loop()
+        except RuntimeError:
+            _LOGGER.warning("No running event loop, callback not scheduled")
+            return None
+
+        # Check for partials to properly determine if coroutine function
+        check_target = target
+        while isinstance(check_target, functools.partial):
+            check_target = check_target.func
+
+        if asyncio.iscoroutine(check_target):
+            return loop.create_task(target)  # type: ignore
+        elif asyncio.iscoroutinefunction(check_target):
+            return loop.create_task(target(*args))
+        else:
+            return loop.run_in_executor(None, target, *args)  # type: ignore
diff --git a/aiopulse/const.py b/aiopulse/const.py
index 82a3c20..2a55f74 100644
--- a/aiopulse/const.py
+++ b/aiopulse/const.py
@@ -1,9 +1,66 @@
 """Acmeda Pulse Hub constants."""
-from enum import Enum
+from enum import Enum, IntEnum
+
+
+class UpdateType(Enum):
+    """Types of hub updates."""
+    info = "info"
+    rollers = "rollers"
+    rooms = "rooms"
+    scenes = "scenes"
+    timers = "timers"
+
+
+class MessageType(IntEnum):
+    """Protocol message types for incoming messages."""
+    HUB_INFO = 0x1600
+    HUB_INFO_UPDATED = 0x0D00
+    ROOM_LIST = 0x0101
+    SCENE_LIST = 0x3301
+    ROLLER_LIST = 0x2101
+    TIMER_LIST = 0x4101
+    AUTH_INFO = 0x0800
+    POSITION = 0x2301
+    ROLLER_UPDATED = 0x2501
+    TIMER_CREATED = 0x4301
+    TIMER_DEVICE_UPDATED = 0x4501
+    TIMER_INFO_UPDATED = 0x4901
+    TIMER_DELETED = 0x4701
+    ROLLER_HEALTH = 0x2B01
+    DISCOVER_RESPONSE = 0x0F00
+
+
+class CommandType(IntEnum):
+    """Command message types."""
+    DISCOVER = 0x03000003
+    CONNECT = 0x03000006
+    LOGIN = 0x0F000008
+    PING = 0x03000015
+    SETID = 0x28000090
+    UNKNOWN1 = 0x23000090
+    GET_HUB_INFO = 0x1E000090
+    MOVE_TO = 0x34000090
+    MOVE = 0x2D000090
+    GET_HEALTH = 0x32000090
+
+
+class ResponseType(IntEnum):
+    """Response message types."""
+    DISCOVER = 0x57000004
+    CONNECT = 0x0F000007
+    LOGIN = 0x04000009
+    PING = 0x03000016
+    SETID = 0x03000091
+    UNKNOWN1 = 0x28000091
+    GET_HUB_INFO = 0x4A000091
+    MOVE_TO = 0x34000091
+    GET_ROOMS = 0x01000091
+    GET_ROLLERS = 0x03000091
 
-UpdateType = Enum("UpdateType", "info rollers rooms scenes timers")
 
 HEADER = bytes.fromhex("00000003")
+
+# Keep old constants for backward compatibility
 COMMAND_DISCOVER = bytes.fromhex("03000003")
 RESPONSE_DISCOVER = bytes.fromhex("57000004")
 
diff --git a/aiopulse/entities.py b/aiopulse/entities.py
new file mode 100644
index 0000000..23b6d6b
--- /dev/null
+++ b/aiopulse/entities.py
@@ -0,0 +1,26 @@
+"""Base class for hub entities."""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from aiopulse.callbacks import CallbackMixin
+
+if TYPE_CHECKING:
+    from aiopulse.hub import Hub
+
+
+class HubEntity(CallbackMixin):
+    """Base class for entities managed by a hub."""
+
+    def __init__(self, hub: Hub, entity_id: bytes | int) -> None:
+        """Initialize entity.
+
+        Args:
+            hub: The hub instance that manages this entity.
+            entity_id: The unique identifier for this entity.
+        """
+        super().__init__()
+        self.hub = hub
+        self.id = entity_id
+        self.name: str | None = None
+        self.icon: int | None = None
diff --git a/aiopulse/errors.py b/aiopulse/errors.py
index 8ddaaeb..90b6d4d 100644
--- a/aiopulse/errors.py
+++ b/aiopulse/errors.py
@@ -1,10 +1,16 @@
 """Error classes of aiopulse module."""
+from __future__ import annotations
 
 
 class HubBaseException(Exception):
     """Base Exception for protocol."""
 
-    pass
+    def __init__(
+        self, message: str = "", response: bytes | None = None
+    ) -> None:
+        """Initialize exception with optional response context."""
+        super().__init__(message)
+        self.response = response
 
 
 class NotConnectedException(HubBaseException):
diff --git a/aiopulse/hub.py b/aiopulse/hub.py
index 8e8f0c0..39f446b 100644
--- a/aiopulse/hub.py
+++ b/aiopulse/hub.py
@@ -3,34 +3,38 @@
 import asyncio
 import binascii
 import logging
-from typing import (
-    Any,
-    List,
-    Callable,
-    Optional,
-)
-import functools
+import warnings
 
 import async_timeout
 
+# from aiopulse import Roller, Room, Scene, Timer
+import aiopulse
 import aiopulse.const as const
-import aiopulse.utils as utils
 import aiopulse.errors as errors
-
-# from aiopulse import Roller, Room, Scene, Timer
 import aiopulse.transport
+import aiopulse.utils as utils
+from aiopulse.callbacks import CallbackMixin
 
 _LOGGER = logging.getLogger(__name__)
 
 
-class Hub:
+class Hub(CallbackMixin):
     """Representation of an Acmeda Pulse Hub."""
 
     def __init__(
-        self, host=None, loop: Optional[asyncio.events.AbstractEventLoop] = None
+        self, host=None, loop: asyncio.events.AbstractEventLoop | None = None
     ):
         """Init the hub."""
-        self.loop: asyncio.events.AbstractEventLoop = loop or asyncio.get_event_loop()
+        super().__init__()
+        if loop is not None:
+            warnings.warn(
+                "loop parameter is deprecated and will be removed in v0.6.0",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            self.loop: asyncio.events.AbstractEventLoop = loop
+        else:
+            self.loop = asyncio.get_running_loop()
         self.topic = str.encode("Smart_Id1_y:")
         self.sequence = 4
         self.handshake = asyncio.Event()
@@ -54,7 +58,6 @@ class Hub:
         self.timers: dict[int, aiopulse.Timer] = {}
 
         self.handshake.clear()
-        self.update_callbacks: List[Callable] = []
 
     def __str__(self):
         """Returns string representation of the hub."""
@@ -66,49 +69,9 @@ class Hub:
             f"WiFi: {self.wifi_module} "
         )
 
-    def callback_subscribe(self, callback):
-        """Add a callback for hub updates."""
-        self.update_callbacks.append(callback)
-
-    def callback_unsubscribe(self, callback):
-        """Remove a callback for hub updates."""
-        if callback in self.update_callbacks:
-            self.update_callbacks.remove(callback)
-
-    def async_add_job(
-        self, target: Callable[..., Any], *args: Any
-    ) -> Optional[asyncio.Future]:
-        """Add a job from within the event loop.
-
-        This method must be run in the event loop.
-
-        target: target to call.
-        args: parameters for method to call.
-        """
-        task = None
-
-        # Check for partials to properly determine if coroutine function
-        check_target = target
-        while isinstance(check_target, functools.partial):
-            check_target = check_target.func
-
-        if asyncio.iscoroutine(check_target):
-            task = self.loop.create_task(target)  # type: ignore
-        elif asyncio.iscoroutinefunction(check_target):
-            task = self.loop.create_task(target(*args))
-        else:
-            task = self.loop.run_in_executor(None, target, *args)  # type: ignore
-
-        return task
-
-    def notify_callback(self, update_type=None):
-        """Tell callback that the hub has been updated."""
-        for callback in self.update_callbacks:
-            self.async_add_job(callback, update_type)
-
     @staticmethod
     async def discover(
-        timeout=5, loop: Optional[asyncio.events.AbstractEventLoop] = None
+        timeout=5, loop: asyncio.events.AbstractEventLoop | None = None
     ):
         """Use a broadcast udp packet to find hubs on the lan."""
         discover_client = aiopulse.transport.HubTransportUdpBroadcast()
@@ -244,6 +207,11 @@ class Hub:
 
     def response_hubinfo(self, message):
         """Receive start of hub information."""
+        if len(message) < 10:
+            raise errors.InvalidResponseException(
+                f"Hub info message too short: {len(message)} bytes",
+                response=message,
+            )
         ptr = 10
         self.firmware_name, ptr = utils.unpack_string(message, ptr)
         ptr += 2
@@ -258,6 +226,11 @@ class Hub:
 
     def response_roller_updated(self, message):
         """Receive change of roller information."""
+        if len(message) < 10:
+            raise errors.InvalidResponseException(
+                f"Roller updated message too short: {len(message)} bytes",
+                response=message,
+            )
         ptr = 2  # sequence?
         ptr += 4
         ptr += 2  # unknown field
@@ -295,6 +268,11 @@ class Hub:
 
     def response_roomlist(self, message):
         """Receive room list."""
+        if len(message) < 12:
+            raise errors.InvalidResponseException(
+                f"Room list message too short: {len(message)} bytes",
+                response=message,
+            )
         ptr = 12
         room_count, ptr = utils.unpack_int(message, ptr, 1)
         for _ in range(room_count):
@@ -312,6 +290,11 @@ class Hub:
 
     def response_rollerlist(self, message):
         """Receive roller blind list."""
+        if len(message) < 12:
+            raise errors.InvalidResponseException(
+                f"Roller list message too short: {len(message)} bytes",
+                response=message,
+            )
         ptr = 2  # sequence?
         ptr += 10
         roller_count, ptr = utils.unpack_int(message, ptr, 1)
@@ -351,6 +334,11 @@ class Hub:
 
     def response_scenelist(self, message):
         """Receive scene list."""
+        if len(message) < 12:
+            raise errors.InvalidResponseException(
+                f"Scene list message too short: {len(message)} bytes",
+                response=message,
+            )
         ptr = 0
         _, ptr = utils.unpack_bytes(message, ptr, 12)
         scene_count, ptr = utils.unpack_int(message, ptr, 1)
@@ -376,6 +364,11 @@ class Hub:
 
     def response_timerlist(self, message):
         """Receive timer list."""
+        if len(message) < 12:
+            raise errors.InvalidResponseException(
+                f"Timer list message too short: {len(message)} bytes",
+                response=message,
+            )
         ptr = 0
         _, ptr = utils.unpack_bytes(message, ptr, 12)
         timer_count, ptr = utils.unpack_int(message, ptr, 1)
@@ -431,11 +424,21 @@ class Hub:
 
     def response_authinfo(self, message):
         """Receive acmeda account information."""
+        if len(message) < 15:
+            raise errors.InvalidResponseException(
+                f"Auth info message too short: {len(message)} bytes",
+                response=message,
+            )
         ptr = 15
         _, ptr = utils.unpack_string(message, ptr)
 
     def response_position(self, message):
         """Receive change of roller position information."""
+        if len(message) < 12:
+            raise errors.InvalidResponseException(
+                f"Position message too short: {len(message)} bytes",
+                response=message,
+            )
         ptr = 12
         roller_id, ptr = utils.unpack_int(message, ptr, 6)
         roller_percent, ptr = utils.unpack_roller_percent(message, ptr)
@@ -448,6 +451,11 @@ class Hub:
 
     def response_rollerhealth(self, message):
         """Receive change of roller health information."""
+        if len(message) < 12:
+            raise errors.InvalidResponseException(
+                f"Roller health message too short: {len(message)} bytes",
+                response=message,
+            )
         ptr = 12
         roller_id, ptr = utils.unpack_int(message, ptr, 6)
         # letter A and then 4 bytes
@@ -483,6 +491,11 @@ class Hub:
 
     def response_discover(self, message):
         """Receive after discover broadcast packet."""
+        if len(message) < 10:
+            raise errors.InvalidResponseException(
+                f"Discover message too short: {len(message)} bytes",
+                response=message,
+            )
         ptr = 0
         _, ptr = utils.unpack_bytes(message, ptr, 10)
         _, ptr = utils.unpack_bytes(message, ptr)
@@ -697,15 +710,13 @@ class Hub:
                 _LOGGER.info(f"{self.host}: Connecting")
                 await self.connect()
                 # await self.update()
-                self.async_add_job(self.update)
+                self._schedule_callback(self.update)
                 await self.response_parser()
             except errors.CannotConnectException as inst:
                 _LOGGER.warning(f"{self.host}: Connect failed {inst}")
             except errors.InvalidResponseException as inst:
                 _LOGGER.warning(f"{self.host}: Handshake failed {inst}")
                 await self.disconnect()
-            except errors.InvalidResponseException as inst:
-                _LOGGER.warning(f"{self.host}: Protocol error {inst}")
             except errors.NotConnectedException:
                 _LOGGER.debug(f"{self.host}: Disconnected, stopping parser")
             except OSError as inst:
diff --git a/aiopulse/py.typed b/aiopulse/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/aiopulse/roller.py b/aiopulse/roller.py
index 01a78a3..e6078e2 100644
--- a/aiopulse/roller.py
+++ b/aiopulse/roller.py
@@ -1,63 +1,77 @@
-"""Elements that hang off the hub."""
+"""Roller blind entity that hangs off the hub."""
+from __future__ import annotations
 
-from typing import List, Callable
-
-import aiopulse.utils as utils
-import aiopulse.const as const
 import asyncio
 import logging
+from typing import TYPE_CHECKING
+
+import aiopulse.const as const
+import aiopulse.utils as utils
+from aiopulse.entities import HubEntity
+
+if TYPE_CHECKING:
+    from aiopulse.hub import Hub
 
 _LOGGER = logging.getLogger(__name__)
 
 
-class Roller:
+class Roller(HubEntity):
     """Representation of a Roller blind."""
 
-    def __init__(self, hub, roller_id):
-        """Init a new roller blind."""
-        self.hub = hub
-        self.id = roller_id
-        self.name = None
-        self.type = None
-        self.serial = None
-        self.room_id = None
+    def __init__(self, hub: Hub, roller_id: int) -> None:
+        """Init a new roller blind.
+
+        Args:
+            hub: The hub instance.
+            roller_id: The unique roller identifier.
+        """
+        super().__init__(hub, roller_id)
+        self.type: int | None = None
+        self.serial: str | None = None
+        self.room_id: bytes | None = None
         self.room = None
-        self.battery = None
-        self.closed_percent = None
-        self.flags = 0
-        self.update_callbacks: List[Callable] = []
+        self.battery: int | None = None
+        self.closed_percent: int | None = None
+        self.flags: int = 0
 
         self.health_lock = asyncio.Lock()
-        self.health_task = hub.async_add_job(self.health_updater)
+        self.health_task = hub._schedule_callback(self.health_updater)
 
-    def __del__(self):
-        self.health_task.cancel()
+    def __del__(self) -> None:
+        """Cancel health updater task on deletion."""
+        if self.health_task:
+            self.health_task.cancel()
 
-    def health_updated(self):
+    def health_updated(self) -> None:
+        """Signal that health data has been received."""
         try:
             self.health_lock.release()
         except RuntimeError:
             pass
 
-    async def health_updater(self):
+    async def health_updater(self) -> None:
+        """Periodically update roller health."""
         await self.get_health()
         running = True
         try:
             while running:
                 try:
-                    await asyncio.wait_for(self.health_lock.acquire(), timeout=3600)
+                    await asyncio.wait_for(
+                        self.health_lock.acquire(), timeout=3600
+                    )
                 except asyncio.TimeoutError:
                     await self.get_health()
                 except asyncio.CancelledError:
                     running = False
         except Exception as inst:
             _LOGGER.error(
-                f"{self.hub.host}:{self.name}: health updater unhandled exception: {inst}"
+                f"{self.hub.host}:{self.name}: health updater "
+                f"unhandled exception: {inst}"
             )
         _LOGGER.info(f"{self.hub.host}:{self.name}: health updater stopped")
         running = False
 
-    def __str__(self):
+    def __str__(self) -> str:
         """Returns string representation of roller."""
         return (
             "Name: {} ID: {} Serial: {} Room: {} Type: {} Closed %: {} Battery %: {}"
@@ -73,22 +87,12 @@ class Roller:
             self.flags,
         )
 
-    def callback_subscribe(self, callback):
-        """Add a callback for hub updates."""
-        self.update_callbacks.append(callback)
-
-    def callback_unsubscribe(self, callback):
-        """Remove a callback for hub updates."""
-        if callback in self.update_callbacks:
-            self.update_callbacks.remove(callback)
+    async def move_to(self, percent: int) -> None:
+        """Send command to move the roller to a percentage closed.
 
-    def notify_callback(self):
-        """Tell callback that device has been updated."""
-        for callback in self.update_callbacks:
-            self.hub.async_add_job(callback)
-
-    async def move_to(self, percent):
-        """Send command to move the roller to a percentage closed."""
+        Args:
+            percent: Target position (0-100).
+        """
         message = (
             bytes.fromhex("0000000000000101")
             + bytes.fromhex("0600")
@@ -102,7 +106,7 @@ class Roller:
             const.COMMAND_MOVE_TO, bytes.fromhex("2201"), message
         )
 
-    async def move_up(self):
+    async def move_up(self) -> None:
         """Send command to move the roller to fully open."""
         message = (
             bytes.fromhex("0000000000000101")
@@ -112,9 +116,11 @@ class Roller:
             + bytes.fromhex("10")
             + bytes.fromhex("ff")
         )
-        await self.hub.send_command(const.COMMAND_MOVE, bytes.fromhex("2201"), message)
+        await self.hub.send_command(
+            const.COMMAND_MOVE, bytes.fromhex("2201"), message
+        )
 
-    async def move_stop(self):
+    async def move_stop(self) -> None:
         """Send command to stop the roller."""
         message = (
             bytes.fromhex("0000000000000101")
@@ -124,9 +130,11 @@ class Roller:
             + bytes.fromhex("11")
             + bytes.fromhex("ff")
         )
-        await self.hub.send_command(const.COMMAND_MOVE, bytes.fromhex("2201"), message)
+        await self.hub.send_command(
+            const.COMMAND_MOVE, bytes.fromhex("2201"), message
+        )
 
-    async def move_down(self):
+    async def move_down(self) -> None:
         """Send command to move the roller to fully closed."""
         message = (
             bytes.fromhex("0000000000000101")
@@ -136,10 +144,12 @@ class Roller:
             + bytes.fromhex("12")
             + bytes.fromhex("ff")
         )
-        await self.hub.send_command(const.COMMAND_MOVE, bytes.fromhex("2201"), message)
+        await self.hub.send_command(
+            const.COMMAND_MOVE, bytes.fromhex("2201"), message
+        )
 
-    async def get_health(self):
-        """."""
+    async def get_health(self) -> None:
+        """Request health information from the roller."""
         message = (
             bytes.fromhex("0000000000000101")
             + bytes.fromhex("0600")
diff --git a/aiopulse/room.py b/aiopulse/room.py
index 0805f4f..88a51cf 100644
--- a/aiopulse/room.py
+++ b/aiopulse/room.py
@@ -1,43 +1,40 @@
-"""Elements that hang off the hub."""
+"""Room entity that hangs off the hub."""
+from __future__ import annotations
 
-from typing import List, Callable
+from typing import TYPE_CHECKING
 
 import aiopulse.utils as utils
 import aiopulse.const as const
-import asyncio
+from aiopulse.entities import HubEntity
 
+if TYPE_CHECKING:
+    from aiopulse.hub import Hub
 
-class Room:
-    """Representation of a Room."""
 
-    def __init__(self, hub, room_id):
-        """Init a new room."""
-        self.hub = hub
-        self.id = room_id
-        self.icon = None
-        self.name = None
-        self.update_callbacks: List[Callable] = []
+class Room(HubEntity):
+    """Representation of a Room."""
 
-    def __str__(self):
-        """Returns string representation of room."""
-        return "Name: {} ID: {} Icon: {}".format(self.name, self.id[0:4], self.icon)
+    def __init__(self, hub: Hub, room_id: bytes) -> None:
+        """Init a new room.
 
-    def callback_subscribe(self, callback):
-        """Add a callback for hub updates."""
-        self.update_callbacks.append(callback)
+        Args:
+            hub: The hub instance.
+            room_id: The unique room identifier.
+        """
+        super().__init__(hub, room_id)
 
-    def callback_unsubscribe(self, callback):
-        """Remove a callback for hub updates."""
-        if callback in self.update_callbacks:
-            self.update_callbacks.remove(callback)
+    def __str__(self) -> str:
+        """Returns string representation of room."""
+        return "Name: {} ID: {} Icon: {}".format(
+            self.name, self.id[0:4] if isinstance(self.id, bytes) else self.id, self.icon
+        )
 
-    def notify_callback(self):
-        """Tell callback that device has been updated."""
-        for callback in self.update_callbacks:
-            self.hub.async_add_job(callback)
+    async def move_to(self, percent: int) -> None:
+        """Send command to move the room to a percentage closed.
 
-    async def move_to(self, percent):
-        """Send command to move the roller to a percentage closed."""
+        Args:
+            percent: Target position (0-100).
+        """
         message = (
             bytes.fromhex("0000000000000101")
             + bytes.fromhex("0600")
@@ -51,8 +48,8 @@ class Room:
             const.COMMAND_MOVE_TO, bytes.fromhex("2201"), message
         )
 
-    async def move_up(self):
-        """Send command to move the roller to fully open."""
+    async def move_up(self) -> None:
+        """Send command to move the room to fully open."""
         message = (
             bytes.fromhex("0000000000000101")
             + bytes.fromhex("0600")
@@ -61,10 +58,12 @@ class Room:
             + bytes.fromhex("10")
             + bytes.fromhex("ff")
         )
-        await self.hub.send_command(const.COMMAND_MOVE, bytes.fromhex("2201"), message)
+        await self.hub.send_command(
+            const.COMMAND_MOVE, bytes.fromhex("2201"), message
+        )
 
-    async def move_stop(self):
-        """Send command to stop the roller."""
+    async def move_stop(self) -> None:
+        """Send command to stop the room."""
         message = (
             bytes.fromhex("0000000000000101")
             + bytes.fromhex("0600")
@@ -73,10 +72,12 @@ class Room:
             + bytes.fromhex("11")
             + bytes.fromhex("ff")
         )
-        await self.hub.send_command(const.COMMAND_MOVE, bytes.fromhex("2201"), message)
+        await self.hub.send_command(
+            const.COMMAND_MOVE, bytes.fromhex("2201"), message
+        )
 
-    async def move_down(self):
-        """Send command to move the roller to fully closed."""
+    async def move_down(self) -> None:
+        """Send command to move the room to fully closed."""
         message = (
             bytes.fromhex("0000000000000101")
             + bytes.fromhex("0600")
@@ -85,4 +86,6 @@ class Room:
             + bytes.fromhex("12")
             + bytes.fromhex("ff")
         )
-        await self.hub.send_command(const.COMMAND_MOVE, bytes.fromhex("2201"), message)
+        await self.hub.send_command(
+            const.COMMAND_MOVE, bytes.fromhex("2201"), message
+        )
diff --git a/aiopulse/scene.py b/aiopulse/scene.py
index c8890b9..a823841 100644
--- a/aiopulse/scene.py
+++ b/aiopulse/scene.py
@@ -1,21 +1,28 @@
-"""Elements that hang off the hub."""
-from typing import List, Callable
+"""Scene entity that hangs off the hub."""
+from __future__ import annotations
 
-import aiopulse.utils as utils
-import aiopulse.const as const
-import asyncio
+from typing import TYPE_CHECKING
 
+from aiopulse.entities import HubEntity
 
-class Scene:
+if TYPE_CHECKING:
+    from aiopulse.hub import Hub
+
+
+class Scene(HubEntity):
     """Representation of a Scene."""
 
-    def __init__(self, hub, scene_id):
-        """Init a new scene."""
-        self.hub = hub
-        self.id = scene_id
-        self.icon = None
-        self.name = None
+    def __init__(self, hub: Hub, scene_id: bytes) -> None:
+        """Init a new scene.
+
+        Args:
+            hub: The hub instance.
+            scene_id: The unique scene identifier.
+        """
+        super().__init__(hub, scene_id)
 
-    def __str__(self):
+    def __str__(self) -> str:
         """Returns string representation of scene."""
-        return "Name: {} ID: {} Icon: {}".format(self.name, self.id[0:4], self.icon)
+        return "Name: {} ID: {} Icon: {}".format(
+            self.name, self.id[0:4] if isinstance(self.id, bytes) else self.id, self.icon
+        )
diff --git a/aiopulse/timer.py b/aiopulse/timer.py
index e9bab16..ec97c47 100644
--- a/aiopulse/timer.py
+++ b/aiopulse/timer.py
@@ -1,34 +1,39 @@
-"""Elements that hang off the hub."""
-from typing import List, Callable
+"""Timer entity that hangs off the hub."""
+from __future__ import annotations
 
-import aiopulse.utils as utils
-import aiopulse.const as const
-import asyncio
+from typing import TYPE_CHECKING
 
+from aiopulse.entities import HubEntity
 
-class Timer:
+if TYPE_CHECKING:
+    from aiopulse.hub import Hub
+
+
+class Timer(HubEntity):
     """Representation of a Timer."""
 
-    def __init__(self, hub, timer_id):
-        """Init a new timer."""
-        self.hub = hub
-        self.id = timer_id
-        self.icon = None
-        self.name = None
-        self.state = None
-        self.hour = None
-        self.minute = None
-        self.days = None
-        self.entity = None
-
-    def __str__(self):
+    def __init__(self, hub: Hub, timer_id: bytes) -> None:
+        """Init a new timer.
+
+        Args:
+            hub: The hub instance.
+            timer_id: The unique timer identifier.
+        """
+        super().__init__(hub, timer_id)
+        self.state: int | None = None
+        self.hour: int | None = None
+        self.minute: int | None = None
+        self.days: int | None = None
+        self.entity: HubEntity | None = None
+
+    def __str__(self) -> str:
         """Returns string representation of timer."""
         return (
             f"Name: {self.name} "
-            f"ID: {self.id[0:4]} "
+            f"ID: {self.id[0:4] if isinstance(self.id, bytes) else self.id} "
             f"Icon: {self.icon} "
             f"State: {self.state} "
             f"Time: {self.hour}:{self.minute} "
-            f"Days: {self.days:>07b} "
+            f"Days: {self.days:>07b} " if self.days is not None else "Days: None "
             f'Entity: {self.entity.name if self.entity else "None"}'
         )
diff --git a/aiopulse/transport.py b/aiopulse/transport.py
index 153cb83..c63de1c 100644
--- a/aiopulse/transport.py
+++ b/aiopulse/transport.py
@@ -48,7 +48,7 @@ class HubTransportUdp(HubTransportBase):
         if host:
             self.host = host
 
-        loop = asyncio.get_event_loop()
+        loop = asyncio.get_running_loop()
         self.transport, self.protocol = await loop.create_datagram_endpoint(
             lambda: self,
             remote_addr=(self.host, self.port),
@@ -61,10 +61,14 @@ class HubTransportUdp(HubTransportBase):
 
     def send(self, buffer):
         """Abstraction of the underlying transport to send a buffer."""
+        if not self.transport:
+            raise NotConnectedException("UDP transport not connected")
         self.transport.sendto(buffer, (self.host, self.port))
 
     async def receive(self):
         """Abstraction of the underlying transport to receive."""
+        if not self.transport:
+            raise NotConnectedException("UDP transport not connected")
         return await self.receive_queue.get()
 
     def datagram_received(self, data, addr):
@@ -87,7 +91,7 @@ class HubTransportUdpBroadcast(HubTransportUdp):
 
         sock.sendto(b"0", ("<broadcast>", 1500))
 
-        loop = asyncio.get_event_loop()
+        loop = asyncio.get_running_loop()
         self.transport, self.protocol = await loop.create_datagram_endpoint(
             lambda: self,
             sock=sock,
@@ -112,9 +116,9 @@ class HubTransportTcp(HubTransportBase):
 
     async def do_connection(self):
         """Try and establish a TCP connection."""
-        loop = asyncio.get_event_loop()
-        self.reader = asyncio.StreamReader(loop=loop)
-        self.protocol = asyncio.StreamReaderProtocol(self.reader, loop=loop)
+        loop = asyncio.get_running_loop()
+        self.reader = asyncio.StreamReader()
+        self.protocol = asyncio.StreamReaderProtocol(self.reader)
 
         # The following blocks until a connection is made
         self.transport, _ = await loop.create_connection(
@@ -166,13 +170,13 @@ class HubTransportTcp(HubTransportBase):
     def send(self, buffer):
         """Abstraction of the underlying transport to send a buffer."""
         if not self.writer or self.writer.is_closing():
-            raise NotConnectedException
+            raise NotConnectedException("TCP transport not connected")
         self.writer.write(buffer)
 
     async def receive(self):
         """Receive from stream."""
-        if self.writer.is_closing():
-            raise NotConnectedException
+        if not self.reader or not self.writer or self.writer.is_closing():
+            raise NotConnectedException("TCP transport not connected")
         return await self.reader.read(65535)
 
     def data_received(self, data):
diff --git a/aiopulse/utils.py b/aiopulse/utils.py
index 43f49b1..a682042 100644
--- a/aiopulse/utils.py
+++ b/aiopulse/utils.py
@@ -1,35 +1,83 @@
-""" Serialisation / Deserialisation helpers """
+"""Serialisation / Deserialisation helpers."""
+from __future__ import annotations
 
 
-def unpack_int(buffer, ptr, length):
-    """Unpack an int of specified length from the buffer and advance the pointer"""
+def unpack_int(buffer: bytes, ptr: int, length: int) -> tuple[int, int]:
+    """Unpack an int of specified length from the buffer and advance the pointer.
+
+    Args:
+        buffer: The bytes buffer to read from.
+        ptr: Current position in the buffer.
+        length: Number of bytes to read.
+
+    Returns:
+        Tuple of (value, new_pointer).
+    """
     return (
         int.from_bytes(buffer[ptr : (ptr + length)], "little", signed=False),
         ptr + length,
     )
 
 
-def pack_int(value, length):
-    """Unpack an int for serialisation"""
+def pack_int(value: int, length: int) -> bytes:
+    """Pack an int for serialisation.
+
+    Args:
+        value: The integer value to pack.
+        length: Number of bytes to use.
+
+    Returns:
+        Bytes representation.
+    """
     return value.to_bytes(length, "little", signed=False)
 
 
-def unpack_bytes(buffer, ptr, length=None):
-    """Unpack a specified number of bytes from the buffer and advance the pointer"""
+def unpack_bytes(
+    buffer: bytes, ptr: int, length: int | None = None
+) -> tuple[bytes, int]:
+    """Unpack a specified number of bytes from the buffer and advance the pointer.
+
+    Args:
+        buffer: The bytes buffer to read from.
+        ptr: Current position in the buffer.
+        length: Number of bytes to read, or None to read length prefix.
+
+    Returns:
+        Tuple of (bytes, new_pointer).
+    """
     ptr_new = ptr
     if not length:
         length, ptr_new = unpack_int(buffer, ptr, 2)
     return (buffer[(ptr_new) : (ptr_new + length)], ptr_new + length)
 
 
-def unpack_string(buffer, ptr, length=None):
-    """Unpack a specified number of characters from the buffer and advance the pointer"""
+def unpack_string(
+    buffer: bytes, ptr: int, length: int | None = None
+) -> tuple[str, int]:
+    """Unpack a specified number of characters from the buffer and advance the pointer.
+
+    Args:
+        buffer: The bytes buffer to read from.
+        ptr: Current position in the buffer.
+        length: Number of bytes to read, or None to read length prefix.
+
+    Returns:
+        Tuple of (string, new_pointer).
+    """
     str_new, ptr_new = unpack_bytes(buffer, ptr, length=None)
     return (str_new.decode("utf-8", "ignore"), ptr_new)
 
 
-def unpack_roller_percent(buffer, ptr):
-    """Unpack roller close percentage."""
+def unpack_roller_percent(buffer: bytes, ptr: int) -> tuple[int, int]:
+    """Unpack roller close percentage.
+
+    Args:
+        buffer: The bytes buffer to read from.
+        ptr: Current position in the buffer.
+
+    Returns:
+        Tuple of (percent, new_pointer).
+    """
     ptr += 4
     roller_state, ptr = unpack_bytes(buffer, ptr, 1)
     ptr += 5  # unknown field
diff --git a/pyproject.toml b/pyproject.toml
index 80dfd3a..a2522ce 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "aiopulse"
-version = "0.4.7"
+version = "0.5.0"
 authors = [
   { name="Alan Murray", email="pypi@atmurray.net" },
 ]
@@ -13,22 +13,54 @@ Asynchronous library to control Rollease Acmeda Automate roller blinds via a ver
 """
 readme = "README.md"
 license = {file = 'LICENSE'}
-requires-python = ">=3.4"
+requires-python = ">=3.10"
 classifiers = [
     "Development Status :: 4 - Beta",
     "Intended Audience :: Developers",
     "Topic :: Software Development :: Libraries :: Python Modules",
     "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
     "License :: OSI Approved :: Apache Software License",
     "Operating System :: OS Independent",
 ]
 
+[project.optional-dependencies]
+dev = [
+    "pytest>=7.0",
+    "pytest-asyncio>=0.21",
+    "pytest-cov>=4.0",
+    "mypy>=1.0",
+    "ruff>=0.4",
+    "black>=24.0",
+    "build>=1.0",
+]
+
 [project.urls]
 Homepage = "https://github.com/atmurray/aiopulse"
 Issues = "https://github.com/atmurray/aiopulse/issues"
 
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+testpaths = ["tests"]
+filterwarnings = [
+    "ignore:loop parameter is deprecated:DeprecationWarning",
+]
+
+[tool.mypy]
+python_version = "3.10"
+warn_return_any = true
+warn_unused_configs = true
+disallow_untyped_defs = false
+
+[tool.ruff]
+target-version = "py310"
+line-length = 88
+select = ["E", "F", "I", "N", "W", "UP"]
+
 [tool.bumpver]
-current_version = "0.4.7"
+current_version = "0.5.0"
 version_pattern = "MAJOR.MINOR.PATCH"
 commit_message  = "Bump version {old_version} -> {new_version}"
 commit          = true
diff --git a/setup.py b/setup.py
index 570a820..b1d3514 100644
--- a/setup.py
+++ b/setup.py
@@ -1,11 +1,11 @@
 """Pip setup file for aiopulse library."""
 
-from distutils.core import setup
+from setuptools import setup
 
 setup(
     name="aiopulse",
     packages=["aiopulse"],
-    version="0.4.7",
+    version="0.5.0",
     license="apache-2.0",
     description="Python module for Rollease Acmeda Automate integration.",
     long_description=open("README.md").read(),
@@ -13,12 +13,12 @@ setup(
     author="Alan Murray",
     author_email="pypi@atmurray.net",
     url="https://github.com/atmurray/aiopulse",
-    download_url="https://github.com/atmurray/aiopulse/archive/v0.4.7.tar.gz",
+    download_url="https://github.com/atmurray/aiopulse/archive/v0.5.0.tar.gz",
     keywords=["automation"],
-    python_requires=">=3.4",
+    python_requires=">=3.10",
     install_requires=["async_timeout"],
     classifiers=[
-        "Development Status :: 3 - Alpha",
+        "Development Status :: 4 - Beta",
         "Intended Audience :: Developers",
         "Operating System :: OS Independent",
         "Topic :: Software Development :: Libraries :: Python Modules",
diff --git a/tests/conftest.py b/tests/conftest.py
index 23102bc..ed8219d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -29,7 +29,7 @@ def hub(event_loop, mock_transport):
     with patch.object(aiopulse.transport, "HubTransportTcp", return_value=mock_transport):
         h = Hub(host="192.168.1.100", loop=event_loop)
         h.running = False
-        h.async_add_job = MagicMock(return_value=MagicMock())
+        h._schedule_callback = MagicMock(return_value=MagicMock())
         yield h
 
 
diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py
new file mode 100644
index 0000000..9f2013c
--- /dev/null
+++ b/tests/test_callbacks.py
@@ -0,0 +1,93 @@
+import asyncio
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from aiopulse.callbacks import CallbackMixin
+
+
+class ConcreteCallbackEntity(CallbackMixin):
+    """Concrete implementation for testing."""
+
+    pass
+
+
+class TestCallbackMixin:
+    @pytest.fixture
+    def entity(self):
+        return ConcreteCallbackEntity()
+
+    def test_init_empty_callbacks(self, entity):
+        assert entity._update_callbacks == []
+
+    def test_callback_subscribe(self, entity):
+        callback = MagicMock()
+        entity.callback_subscribe(callback)
+        assert callback in entity._update_callbacks
+
+    def test_callback_unsubscribe(self, entity):
+        callback = MagicMock()
+        entity._update_callbacks.append(callback)
+        entity.callback_unsubscribe(callback)
+        assert callback not in entity._update_callbacks
+
+    def test_callback_unsubscribe_not_found(self, entity):
+        callback = MagicMock()
+        entity.callback_unsubscribe(callback)
+        assert entity._update_callbacks == []
+
+    def test_callback_multiple_subscribers(self, entity):
+        callback1 = MagicMock()
+        callback2 = MagicMock()
+        entity.callback_subscribe(callback1)
+        entity.callback_subscribe(callback2)
+        assert len(entity._update_callbacks) == 2
+        assert callback1 in entity._update_callbacks
+        assert callback2 in entity._update_callbacks
+
+    @pytest.mark.asyncio
+    async def test_notify_callback_sync(self, entity):
+        callback = MagicMock()
+        entity._update_callbacks.append(callback)
+        entity.notify_callback()
+        await asyncio.sleep(0.1)
+        callback.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_notify_callback_with_args(self, entity):
+        callback = MagicMock()
+        entity._update_callbacks.append(callback)
+        entity.notify_callback("arg1", "arg2")
+        await asyncio.sleep(0.1)
+        callback.assert_called_once_with("arg1", "arg2")
+
+    @pytest.mark.asyncio
+    async def test_notify_callback_multiple(self, entity):
+        callback1 = MagicMock()
+        callback2 = MagicMock()
+        entity._update_callbacks.append(callback1)
+        entity._update_callbacks.append(callback2)
+        entity.notify_callback()
+        await asyncio.sleep(0.1)
+        callback1.assert_called_once()
+        callback2.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_notify_callback_async(self, entity):
+        callback = AsyncMock()
+        entity._update_callbacks.append(callback)
+        entity.notify_callback()
+        await asyncio.sleep(0.1)
+        callback.assert_called_once()
+
+    def test_notify_callback_coroutine(self, entity):
+        called = False
+
+        async def callback():
+            nonlocal called
+            called = True
+
+        entity._update_callbacks.append(callback)
+        entity.notify_callback()
+        # Note: coroutine won't execute without event loop running
+        # This tests that it doesn't raise
diff --git a/tests/test_const.py b/tests/test_const.py
index 8fae8b9..9b8805a 100644
--- a/tests/test_const.py
+++ b/tests/test_const.py
@@ -2,6 +2,9 @@ import pytest
 
 from aiopulse.const import (
     UpdateType,
+    MessageType,
+    CommandType,
+    ResponseType,
     HEADER,
     COMMAND_DISCOVER,
     RESPONSE_DISCOVER,
@@ -26,20 +29,52 @@ from aiopulse.const import (
 )
 
 
+class TestMessageType:
+    def test_message_type_values(self):
+        assert MessageType.HUB_INFO == 0x1600
+        assert MessageType.ROOM_LIST == 0x0101
+        assert MessageType.ROLLER_LIST == 0x2101
+        assert MessageType.SCENE_LIST == 0x3301
+        assert MessageType.TIMER_LIST == 0x4101
+        assert MessageType.POSITION == 0x2301
+        assert MessageType.ROLLER_UPDATED == 0x2501
+        assert MessageType.ROLLER_HEALTH == 0x2B01
+
+    def test_message_type_is_int(self):
+        assert isinstance(MessageType.HUB_INFO, int)
+
+
+class TestCommandType:
+    def test_command_type_values(self):
+        assert CommandType.DISCOVER == 0x03000003
+        assert CommandType.CONNECT == 0x03000006
+        assert CommandType.LOGIN == 0x0F000008
+        assert CommandType.PING == 0x03000015
+        assert CommandType.SETID == 0x28000090
+        assert CommandType.GET_HUB_INFO == 0x1E000090
+        assert CommandType.MOVE_TO == 0x34000090
+        assert CommandType.MOVE == 0x2D000090
+        assert CommandType.GET_HEALTH == 0x32000090
+
+
+class TestResponseType:
+    def test_response_type_values(self):
+        assert ResponseType.DISCOVER == 0x57000004
+        assert ResponseType.CONNECT == 0x0F000007
+        assert ResponseType.LOGIN == 0x04000009
+        assert ResponseType.PING == 0x03000016
+        assert ResponseType.SETID == 0x03000091
+        assert ResponseType.GET_HUB_INFO == 0x4A000091
+        assert ResponseType.MOVE_TO == 0x34000091
+
+
 class TestUpdateType:
-    def test_enum_values(self):
-        assert UpdateType.info.value == 1
-        assert UpdateType.rollers.value == 2
-        assert UpdateType.rooms.value == 3
-        assert UpdateType.scenes.value == 4
-        assert UpdateType.timers.value == 5
-
-    def test_enum_members(self):
-        assert UpdateType(1) == UpdateType.info
-        assert UpdateType(2) == UpdateType.rollers
-        assert UpdateType(3) == UpdateType.rooms
-        assert UpdateType(4) == UpdateType.scenes
-        assert UpdateType(5) == UpdateType.timers
+    def test_update_type_values(self):
+        assert UpdateType.info.name == "info"
+        assert UpdateType.rollers.name == "rollers"
+        assert UpdateType.rooms.name == "rooms"
+        assert UpdateType.scenes.name == "scenes"
+        assert UpdateType.timers.name == "timers"
 
 
 class TestConstants:
diff --git a/tests/test_entities.py b/tests/test_entities.py
new file mode 100644
index 0000000..acfedb8
--- /dev/null
+++ b/tests/test_entities.py
@@ -0,0 +1,55 @@
+import asyncio
+
+import pytest
+from unittest.mock import MagicMock
+
+from aiopulse.entities import HubEntity
+
+
+class ConcreteEntity(HubEntity):
+    """Concrete implementation for testing."""
+
+    pass
+
+
+class TestHubEntity:
+    @pytest.fixture
+    def hub_mock(self):
+        hub = MagicMock()
+        hub.host = "192.168.1.100"
+        return hub
+
+    @pytest.fixture
+    def entity(self, hub_mock):
+        return ConcreteEntity(hub=hub_mock, entity_id=123)
+
+    def test_init_with_int_id(self, hub_mock):
+        entity = ConcreteEntity(hub=hub_mock, entity_id=123)
+        assert entity.hub == hub_mock
+        assert entity.id == 123
+        assert entity.name is None
+        assert entity.icon is None
+        assert entity._update_callbacks == []
+
+    def test_init_with_bytes_id(self, hub_mock):
+        entity = ConcreteEntity(hub=hub_mock, entity_id=b"\x01\x02\x03\x04")
+        assert entity.id == b"\x01\x02\x03\x04"
+
+    def test_callback_subscribe(self, entity):
+        callback = MagicMock()
+        entity.callback_subscribe(callback)
+        assert callback in entity._update_callbacks
+
+    def test_callback_unsubscribe(self, entity):
+        callback = MagicMock()
+        entity._update_callbacks.append(callback)
+        entity.callback_unsubscribe(callback)
+        assert callback not in entity._update_callbacks
+
+    @pytest.mark.asyncio
+    async def test_notify_callback(self, entity):
+        callback = MagicMock()
+        entity._update_callbacks.append(callback)
+        entity.notify_callback()
+        await asyncio.sleep(0.1)
+        callback.assert_called_once()
diff --git a/tests/test_errors.py b/tests/test_errors.py
index 439e277..148fce7 100644
--- a/tests/test_errors.py
+++ b/tests/test_errors.py
@@ -41,3 +41,45 @@ class TestErrors:
         assert issubclass(CannotConnectException, HubBaseException)
         assert issubclass(InvalidResponseException, HubBaseException)
         assert issubclass(HubBaseException, Exception)
+
+
+class TestHubBaseException:
+    def test_basic_creation(self):
+        exc = HubBaseException("test message")
+        assert str(exc) == "test message"
+
+    def test_with_response_context(self):
+        response = b"\x00\x01\x02\x03"
+        exc = HubBaseException("test message", response=response)
+        assert exc.response == response
+
+
+class TestInvalidResponseException:
+    def test_basic_creation(self):
+        exc = InvalidResponseException("invalid response")
+        assert str(exc) == "invalid response"
+        assert exc.response is None
+
+    def test_with_response(self):
+        response = b"\xff\xff\xff\xff"
+        exc = InvalidResponseException("invalid header", response=response)
+        assert exc.response == response
+        assert b"\xff\xff" in exc.response
+
+
+class TestNotConnectedException:
+    def test_basic_creation(self):
+        exc = NotConnectedException("not connected")
+        assert str(exc) == "not connected"
+
+
+class TestCannotConnectException:
+    def test_basic_creation(self):
+        exc = CannotConnectException("connection refused")
+        assert str(exc) == "connection refused"
+
+
+class TestNotRunningException:
+    def test_basic_creation(self):
+        exc = NotRunningException("not running")
+        assert str(exc) == "not running"
diff --git a/tests/test_hub.py b/tests/test_hub.py
index 94ea12b..01a7fd2 100644
--- a/tests/test_hub.py
+++ b/tests/test_hub.py
@@ -1,4 +1,5 @@
 import asyncio
+import warnings
 from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch, call
 
 import pytest
@@ -30,7 +31,7 @@ class TestHubInit:
         assert hub.rooms == {}
         assert hub.scenes == {}
         assert hub.timers == {}
-        assert hub.update_callbacks == []
+        assert hub._update_callbacks == []
 
     def test_init_without_host(self, event_loop):
         mock_transport = MagicMock(spec=aiopulse.transport.HubTransportTcp)
@@ -50,48 +51,46 @@ class TestHubInit:
         assert "v1.0" in result
 
 
+class TestHubDeprecation:
+    def test_loop_parameter_deprecation(self, event_loop, mock_transport):
+        """Passing loop parameter should emit DeprecationWarning."""
+        with patch.object(aiopulse.transport, "HubTransportTcp", return_value=mock_transport):
+            with warnings.catch_warnings(record=True) as w:
+                warnings.simplefilter("always")
+                Hub(host="192.168.1.100", loop=event_loop)
+                assert len(w) == 1
+                assert issubclass(w[0].category, DeprecationWarning)
+                assert "loop parameter is deprecated" in str(w[0].message)
+
+    def test_no_loop_no_warning(self, mock_transport):
+        """When loop is None and get_running_loop succeeds, no deprecation warning."""
+        with patch.object(aiopulse.transport, "HubTransportTcp", return_value=mock_transport):
+            with patch("asyncio.get_running_loop") as mock_get_loop:
+                mock_get_loop.return_value = MagicMock()
+                with warnings.catch_warnings(record=True) as w:
+                    warnings.simplefilter("always")
+                    Hub(host="192.168.1.100")
+                    assert len(w) == 0
+                    mock_get_loop.assert_called_once()
+
+
 class TestHubCallbacks:
     def test_callback_subscribe(self, hub):
         callback = MagicMock()
         hub.callback_subscribe(callback)
-        assert callback in hub.update_callbacks
+        assert callback in hub._update_callbacks
 
     def test_callback_unsubscribe(self, hub):
         callback = MagicMock()
-        hub.update_callbacks.append(callback)
+        hub._update_callbacks.append(callback)
         hub.callback_unsubscribe(callback)
-        assert callback not in hub.update_callbacks
+        assert callback not in hub._update_callbacks
 
     def test_notify_callback(self, hub):
         callback = MagicMock()
-        hub.update_callbacks.append(callback)
+        hub._update_callbacks.append(callback)
         hub.notify_callback(const.UpdateType.info)
-        hub.async_add_job.assert_called_with(callback, const.UpdateType.info)
-
-
-class TestHubAsyncAddJob:
-    def test_async_add_job_coroutine(self, hub):
-        async def fake_coro():
-            pass
-
-        coro = fake_coro()
-        task = hub.async_add_job(coro)
-        assert task is not None
-        coro.close()
-
-    def test_async_add_job_coroutine_function(self, hub):
-        async def fake_func():
-            pass
-
-        task = hub.async_add_job(fake_func)
-        assert task is not None
-
-    def test_async_add_job_regular_function(self, hub):
-        def fake_func():
-            pass
-
-        task = hub.async_add_job(fake_func)
-        assert task is not None
+        hub._schedule_callback.assert_called_with(callback, const.UpdateType.info)
 
 
 class TestHubConnect:
@@ -404,6 +403,68 @@ class TestHubResponseHandlers:
         assert roller_mock.battery == 19
 
 
+class TestHubBoundsChecking:
+    def test_response_hubinfo_too_short(self, hub):
+        """response_hubinfo should raise InvalidResponseException if message too short."""
+        short_message = b"\x00" * 9  # less than 10 bytes
+        with pytest.raises(InvalidResponseException):
+            hub.response_hubinfo(short_message)
+
+    def test_response_roller_updated_too_short(self, hub):
+        """response_roller_updated should raise InvalidResponseException if message too short."""
+        short_message = b"\x00" * 9
+        with pytest.raises(InvalidResponseException):
+            hub.response_roller_updated(short_message)
+
+    def test_response_roomlist_too_short(self, hub):
+        """response_roomlist should raise InvalidResponseException if message too short."""
+        short_message = b"\x00" * 11
+        with pytest.raises(InvalidResponseException):
+            hub.response_roomlist(short_message)
+
+    def test_response_rollerlist_too_short(self, hub):
+        """response_rollerlist should raise InvalidResponseException if message too short."""
+        short_message = b"\x00" * 11
+        with pytest.raises(InvalidResponseException):
+            hub.response_rollerlist(short_message)
+
+    def test_response_scenelist_too_short(self, hub):
+        """response_scenelist should raise InvalidResponseException if message too short."""
+        short_message = b"\x00" * 11
+        with pytest.raises(InvalidResponseException):
+            hub.response_scenelist(short_message)
+
+    def test_response_timerlist_too_short(self, hub):
+        """response_timerlist should raise InvalidResponseException if message too short."""
+        short_message = b"\x00" * 11
+        with pytest.raises(InvalidResponseException):
+            hub.response_timerlist(short_message)
+
+    def test_response_authinfo_too_short(self, hub):
+        """response_authinfo should raise InvalidResponseException if message too short."""
+        short_message = b"\x00" * 14
+        with pytest.raises(InvalidResponseException):
+            hub.response_authinfo(short_message)
+
+    def test_response_position_too_short(self, hub):
+        """response_position should raise InvalidResponseException if message too short."""
+        short_message = b"\x00" * 11
+        with pytest.raises(InvalidResponseException):
+            hub.response_position(short_message)
+
+    def test_response_rollerhealth_too_short(self, hub):
+        """response_rollerhealth should raise InvalidResponseException if message too short."""
+        short_message = b"\x00" * 11
+        with pytest.raises(InvalidResponseException):
+            hub.response_rollerhealth(short_message)
+
+    def test_response_discover_too_short(self, hub):
+        """response_discover should raise InvalidResponseException if message too short."""
+        short_message = b"\x00" * 9
+        with pytest.raises(InvalidResponseException):
+            hub.response_discover(short_message)
+
+
 class TestHubRecMessage:
     def test_rec_message_invalid_first_byte(self, hub):
         with pytest.raises(InvalidResponseException):
@@ -613,6 +674,30 @@ class TestHubRunStop:
         assert hub.running is False
         hub.disconnect.assert_called_once()
 
+    @pytest.mark.asyncio
+    async def test_run_invalid_response_disconnects(self, hub, mock_transport):
+        """InvalidResponseException during connect should call disconnect."""
+        hub.handshake.clear()
+
+        async def connect_and_stop():
+            hub.running = False
+            raise InvalidResponseException("test")
+
+        hub.connect = AsyncMock(side_effect=connect_and_stop)
+        hub.disconnect = AsyncMock()
+        mock_transport.close = AsyncMock()
+
+        await hub.run()
+        hub.disconnect.assert_called()
+
+    @pytest.mark.asyncio
+    async def test_run_invalid_response_no_duplicate_catch(self, hub, mock_transport):
+        """Only one InvalidResponseException catch block should exist in run()."""
+        import inspect
+        source = inspect.getsource(hub.run)
+        count = source.count("except errors.InvalidResponseException")
+        assert count == 1, f"Expected 1 InvalidResponseException catch, found {count}"
+
     @pytest.mark.asyncio
     async def test_stop_already_stopped(self, hub):
         hub.running = False
diff --git a/tests/test_roller.py b/tests/test_roller.py
index 1a56b8d..ee7260f 100644
--- a/tests/test_roller.py
+++ b/tests/test_roller.py
@@ -11,7 +11,7 @@ class TestRoller:
     def hub_mock(self):
         hub = MagicMock()
         hub.host = "192.168.1.100"
-        hub.async_add_job = MagicMock(return_value=MagicMock())
+        hub._schedule_callback = MagicMock(return_value=MagicMock())
         return hub
 
     @pytest.fixture
@@ -31,7 +31,11 @@ class TestRoller:
         assert roller.battery is None
         assert roller.closed_percent is None
         assert roller.flags == 0
-        assert roller.update_callbacks == []
+        assert roller._update_callbacks == []
+
+    def test_init_inherits_hub_entity(self, roller):
+        assert roller.hub is not None
+        assert roller.id == 123
 
     def test_str(self, roller):
         roller.name = "Test Blind"
@@ -54,24 +58,25 @@ class TestRoller:
     def test_callback_subscribe(self, roller):
         callback = MagicMock()
         roller.callback_subscribe(callback)
-        assert callback in roller.update_callbacks
+        assert callback in roller._update_callbacks
 
     def test_callback_unsubscribe(self, roller):
         callback = MagicMock()
-        roller.update_callbacks.append(callback)
+        roller._update_callbacks.append(callback)
         roller.callback_unsubscribe(callback)
-        assert callback not in roller.update_callbacks
+        assert callback not in roller._update_callbacks
 
     def test_callback_unsubscribe_not_found(self, roller):
         callback = MagicMock()
         roller.callback_unsubscribe(callback)
-        assert roller.update_callbacks == []
+        assert roller._update_callbacks == []
 
     def test_notify_callback(self, roller):
         callback = MagicMock()
-        roller.update_callbacks.append(callback)
-        roller.notify_callback()
-        roller.hub.async_add_job.assert_called_with(callback)
+        roller._update_callbacks.append(callback)
+        with patch.object(roller, "_schedule_callback") as mock_schedule:
+            roller.notify_callback()
+            mock_schedule.assert_called_once_with(callback)
 
     @pytest.mark.asyncio
     async def test_move_to(self, roller):
diff --git a/tests/test_room.py b/tests/test_room.py
index 65fc027..bd6081c 100644
--- a/tests/test_room.py
+++ b/tests/test_room.py
@@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock
 import pytest
 
 from aiopulse.room import Room
+from aiopulse.entities import HubEntity
 
 
 class TestRoom:
@@ -10,19 +11,22 @@ class TestRoom:
     def hub_mock(self):
         hub = MagicMock()
         hub.host = "192.168.1.100"
-        hub.async_add_job = MagicMock()
+        hub._schedule_callback = MagicMock()
         return hub
 
     @pytest.fixture
     def room(self, hub_mock):
-        r = Room(hub_mock, b"\x01\x00\x00\x00\x00\x00")
-        return r
+        return Room(hub_mock, b"\x01\x00\x00\x00\x00\x00")
 
     def test_init(self, room):
         assert room.id == b"\x01\x00\x00\x00\x00\x00"
         assert room.icon is None
         assert room.name is None
-        assert room.update_callbacks == []
+        assert room.hub is not None
+        assert room._update_callbacks == []
+
+    def test_inherits_from_hub_entity(self, room):
+        assert isinstance(room, HubEntity)
 
     def test_str(self, room):
         room.name = "Living Room"
@@ -34,28 +38,22 @@ class TestRoom:
     def test_callback_subscribe(self, room):
         callback = MagicMock()
         room.callback_subscribe(callback)
-        assert callback in room.update_callbacks
+        assert callback in room._update_callbacks
 
     def test_callback_unsubscribe(self, room):
         callback = MagicMock()
-        room.update_callbacks.append(callback)
+        room._update_callbacks.append(callback)
         room.callback_unsubscribe(callback)
-        assert callback not in room.update_callbacks
+        assert callback not in room._update_callbacks
 
     def test_callback_unsubscribe_not_found(self, room):
         callback = MagicMock()
         room.callback_unsubscribe(callback)
-        assert room.update_callbacks == []
-
-    def test_notify_callback(self, room):
-        callback = MagicMock()
-        room.update_callbacks.append(callback)
-        room.notify_callback()
-        room.hub.async_add_job.assert_called_with(callback)
+        assert room._update_callbacks == []
 
     @pytest.mark.asyncio
     async def test_move_to(self, room):
-        room.id = 1  # pack_int needs int, not bytes
+        room.id = 1
         room.hub.send_command = AsyncMock()
         await room.move_to(50)
         room.hub.send_command.assert_awaited_once()
diff --git a/tests/test_scene.py b/tests/test_scene.py
index 401738c..4a95956 100644
--- a/tests/test_scene.py
+++ b/tests/test_scene.py
@@ -1,3 +1,7 @@
+import asyncio
+
+from unittest.mock import MagicMock
+
 import pytest
 
 from aiopulse.scene import Scene
@@ -6,24 +10,42 @@ from aiopulse.scene import Scene
 class TestScene:
     @pytest.fixture
     def hub_mock(self):
-        return type("HubMock", (), {"host": "192.168.1.100"})()
+        hub = MagicMock()
+        hub.host = "192.168.1.100"
+        return hub
 
     @pytest.fixture
     def scene(self, hub_mock):
-        return Scene(hub_mock, b"\x01\x00\x00\x00\x00\x00")
+        return Scene(hub_mock, b"\x01\x02\x03\x04")
 
     def test_init(self, scene):
-        assert scene.id == b"\x01\x00\x00\x00\x00\x00"
-        assert scene.icon is None
+        assert scene.id == b"\x01\x02\x03\x04"
         assert scene.name is None
+        assert scene.icon is None
+        assert scene.hub is not None
+        assert scene._update_callbacks == []
 
-    def test_str_with_values(self, scene):
-        scene.name = "Test Scene"
-        scene.icon = 3
+    def test_str(self, scene):
+        scene.name = "Movie Night"
+        scene.icon = 5
         result = str(scene)
-        assert "Test Scene" in result
-        assert "Icon: 3" in result
+        assert "Movie Night" in result
 
-    def test_str_no_name(self, scene):
-        result = str(scene)
-        assert "None" in result or "Name: None" in result
+    def test_callback_subscribe(self, scene):
+        callback = MagicMock()
+        scene.callback_subscribe(callback)
+        assert callback in scene._update_callbacks
+
+    def test_callback_unsubscribe(self, scene):
+        callback = MagicMock()
+        scene._update_callbacks.append(callback)
+        scene.callback_unsubscribe(callback)
+        assert callback not in scene._update_callbacks
+
+    @pytest.mark.asyncio
+    async def test_notify_callback(self, scene):
+        callback = MagicMock()
+        scene._update_callbacks.append(callback)
+        scene.notify_callback()
+        await asyncio.sleep(0.1)
+        callback.assert_called_once()
diff --git a/tests/test_timer.py b/tests/test_timer.py
index 51b5ecb..4a51091 100644
--- a/tests/test_timer.py
+++ b/tests/test_timer.py
@@ -1,3 +1,6 @@
+# tests/test_timer.py
+from unittest.mock import MagicMock
+
 import pytest
 
 from aiopulse.timer import Timer
@@ -6,46 +9,69 @@ from aiopulse.timer import Timer
 class TestTimer:
     @pytest.fixture
     def hub_mock(self):
-        hub = type("HubMock", (), {"host": "192.168.1.100"})()
-        hub.rollers = {}
-        hub.scenes = {}
+        hub = MagicMock()
+        hub.host = "192.168.1.100"
         return hub
 
     @pytest.fixture
     def timer(self, hub_mock):
-        return Timer(hub_mock, b"\x01\x00\x00\x00\x00\x00")
+        return Timer(hub_mock, b"\x01\x02\x03\x04")
 
     def test_init(self, timer):
-        assert timer.id == b"\x01\x00\x00\x00\x00\x00"
-        assert timer.icon is None
+        assert timer.id == b"\x01\x02\x03\x04"
         assert timer.name is None
+        assert timer.icon is None
         assert timer.state is None
         assert timer.hour is None
         assert timer.minute is None
         assert timer.days is None
         assert timer.entity is None
+        assert timer.hub is not None
+        assert timer._update_callbacks == []
 
-    def test_str_all_fields(self, timer):
-        timer.name = "Morning"
-        timer.icon = 5
+    def test_str(self, timer):
+        timer.name = "Morning Timer"
+        timer.icon = 2
         timer.state = 1
         timer.hour = 7
         timer.minute = 30
-        timer.days = 0b01111111
+        timer.days = 0b0111110
         result = str(timer)
-        assert "Morning" in result
-        assert "State: 1" in result
-        assert "Time: 7:30" in result
-        assert "Entity: None" in result
-
-    def test_str_with_entity(self, timer, hub_mock):
-        roller_mock = type("RollerMock", (), {"name": "Living Room"})()
-        timer.entity = roller_mock
-        timer.name = "Test"
-        timer.icon = 1
-        timer.state = 1
-        timer.hour = 8
-        timer.minute = 0
-        timer.days = 0
+        assert "Morning Timer" in result
+        assert "7:30" in result
+
+    def test_str_with_entity(self, timer):
+        timer.name = "Morning Timer"
+        timer.entity = MagicMock()
+        timer.entity.name = "Living Room Blind"
+        result = str(timer)
+        assert "Living Room Blind" in result
+
+    def test_str_without_entity(self, timer):
+        timer.name = "Morning Timer"
         result = str(timer)
-        assert "Living Room" in result
+        assert "None" in result
+
+    def test_callback_subscribe(self, timer):
+        callback = MagicMock()
+        timer.callback_subscribe(callback)
+        assert callback in timer._update_callbacks
+
+    def test_callback_unsubscribe(self, timer):
+        callback = MagicMock()
+        timer._update_callbacks.append(callback)
+        timer.callback_unsubscribe(callback)
+        assert callback not in timer._update_callbacks
+
+    def test_notify_callback(self, timer):
+        import asyncio
+
+        callback = MagicMock()
+        timer._update_callbacks.append(callback)
+
+        async def run():
+            timer.notify_callback()
+            await asyncio.sleep(0)
+
+        asyncio.run(run())
+        callback.assert_called_once()
diff --git a/tests/test_transport.py b/tests/test_transport.py
index c276263..9b27ca1 100644
--- a/tests/test_transport.py
+++ b/tests/test_transport.py
@@ -48,7 +48,7 @@ class TestHubTransportUdp:
 
     @pytest.mark.asyncio
     async def test_connect(self, udp):
-        with patch.object(asyncio, "get_event_loop") as mock_get_loop:
+        with patch.object(asyncio, "get_running_loop") as mock_get_loop:
             mock_loop = MagicMock()
             mock_get_loop.return_value = mock_loop
             mock_loop.create_datagram_endpoint = AsyncMock(
@@ -70,6 +70,11 @@ class TestHubTransportUdp:
         udp.send(b"test")
         udp.transport.sendto.assert_called_once_with(b"test", ("192.168.1.100", 12414))
 
+    def test_send_not_connected(self, udp):
+        udp.transport = None
+        with pytest.raises(NotConnectedException):
+            udp.send(b"test")
+
     def test_send_uses_host_port(self, udp):
         udp.transport = MagicMock()
         udp.host = "10.0.0.5"
@@ -79,6 +84,7 @@ class TestHubTransportUdp:
 
     @pytest.mark.asyncio
     async def test_receive(self, udp):
+        udp.transport = MagicMock()
         expected = (b"response", ("192.168.1.100", 12414))
         udp.receive_queue.put_nowait(expected)
         result = await udp.receive()
@@ -89,12 +95,18 @@ class TestHubTransportUdp:
         result = udp.receive_queue.get_nowait()
         assert result == (b"data", ("1.2.3.4", 5678))
 
+    @pytest.mark.asyncio
+    async def test_receive_not_connected(self, udp):
+        udp.transport = None
+        with pytest.raises(NotConnectedException):
+            await udp.receive()
+
 
 class TestHubTransportUdpBroadcast:
     @pytest.mark.asyncio
     async def test_connect(self):
         transport = HubTransportUdpBroadcast()
-        with patch.object(asyncio, "get_event_loop") as mock_get_loop, \
+        with patch.object(asyncio, "get_running_loop") as mock_get_loop, \
              patch("socket.socket") as mock_socket:
             mock_loop = MagicMock()
             mock_get_loop.return_value = mock_loop
@@ -125,7 +137,7 @@ class TestHubTransportTcp:
 
     @pytest.mark.asyncio
     async def test_do_connection(self, tcp):
-        with patch.object(asyncio, "get_event_loop") as mock_get_loop:
+        with patch.object(asyncio, "get_running_loop") as mock_get_loop:
             mock_loop = MagicMock()
             mock_get_loop.return_value = mock_loop
             mock_loop.create_connection = AsyncMock(
@@ -211,6 +223,13 @@ class TestHubTransportTcp:
         with pytest.raises(NotConnectedException):
             await tcp.receive()
 
+    @pytest.mark.asyncio
+    async def test_receive_not_connected(self, tcp):
+        tcp.writer = None
+        tcp.reader = None
+        with pytest.raises(NotConnectedException):
+            await tcp.receive()
+
     @pytest.mark.asyncio
     async def test_close_with_writer(self, tcp):
         mock_writer = MagicMock()
