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/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
