Repository: etsinko/pymonoprice

Issue:
Use async keyword instead of asyncio.coroutine
Python 3.5 introduced the `async` and `await` syntax to work with async functions. The `@asyncio.coroutine` decorator has been deprecated as of Python 3.8 and prints a warning when it's used ([example](https://github.com/home-assistant/core/runs/1342394326?check_suite_focus=true#step:8:355)). It is going to be removed in Python 3.10.

Rewriting it is straight forward:

```python
# before
@asyncio.coroutine
def my_func():
    yield from other_func()

# new
async def my_func():
    await other_func()
```

To be future proof for future Home Assistant versions, it would be good to migrate to `async` and `await` syntax.

Discussion:

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..bc76bb1
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,49 @@
+name: CI
+on:
+  workflow_dispatch:
+  push:
+  pull_request:
+
+jobs:
+  unit-test:
+    name: Python ${{ matrix.python-version}} unit tests
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version:
+          - "3.7"
+          - "3.8"
+          - "3.9"
+          - "3.10"
+          - "3.11-dev"
+    steps:
+      - name: Checkout repo
+        uses: actions/checkout@v3
+      - name: Setup Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          pip install -r requirements.txt
+      - name: Run unit tests
+        run: python -m unittest
+  mypy-test:
+    name: mypy test
+    runs-on: ubuntu-latest
+    env:
+      python-version: "3.10"
+    steps:
+      - name: Checkout repo
+        uses: actions/checkout@v3
+      - name: Setup Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ env.python-version }}
+      - name: Install dependencies
+        run: |
+          pip install -r requirements.txt
+          pip install mypy
+      - name: Run mypy test
+        run: mypy -p pymonoprice
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..73e660f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+.mypy_cache
+__pycache__
+*.swp
+
+build
+dist
+*.egg-info
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..c9d3d5f
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,3 @@
+include *.txt
+include .coveragerc
+recursive-include tests *.py
diff --git a/pymonoprice/__init__.py b/pymonoprice/__init__.py
index 8bda789..dbb9589 100644
--- a/pymonoprice/__init__.py
+++ b/pymonoprice/__init__.py
@@ -1,172 +1,510 @@
+from __future__ import annotations
+
 import asyncio
-import functools
 import logging
 import re
 import serial
+from dataclasses import dataclass
 from functools import wraps
-from serial_asyncio import create_serial_connection
+from serial_asyncio import create_serial_connection, SerialTransport
 from threading import RLock
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from typing import Awaitable, Callable, Concatenate, ParamSpec, TypeVar
+
+    _P = ParamSpec("_P")
+    _T = TypeVar("_T")
+    _AsyncLockable = TypeVar("_AsyncLockable", "MonopriceAsync", "MonopriceProtocol")
 
 _LOGGER = logging.getLogger(__name__)
-ZONE_PATTERN = re.compile('#>(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)')
+ZONE_PATTERN = re.compile(
+    r">(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)"
+)
 
-EOL = b'\r\n#'
+EOL = b"\r\n#"
 LEN_EOL = len(EOL)
 TIMEOUT = 2  # Number of seconds before serial operation timeout
 
 
-class ZoneStatus(object):
-    def __init__(self,
-                 zone: int,
-                 pa: bool,
-                 power: bool,
-                 mute: bool,
-                 do_not_disturb: bool,
-                 volume: int,  # 0 - 38
-                 treble: int,  # 0 -> -7,  14-> +7
-                 bass: int,  # 0 -> -7,  14-> +7
-                 balance: int,  # 00 - left, 10 - center, 20 right
-                 source: int,
-                 keypad: bool):
-        self.zone = zone
-        self.pa = bool(pa)
-        self.power = bool(power)
-        self.mute = bool(mute)
-        self.do_not_disturb = bool(do_not_disturb)
-        self.volume = volume
-        self.treble = treble
-        self.bass = bass
-        self.balance = balance
-        self.source = source
-        self.keypad = bool(keypad)
+def synchronized(
+    func: Callable[Concatenate[Monoprice, _P], _T]
+) -> Callable[Concatenate[Monoprice, _P], _T]:
+    @wraps(func)
+    def wrapper(self: Monoprice, *args: _P.args, **kwargs: _P.kwargs) -> _T:
+        with self._lock:
+            return func(self, *args, **kwargs)
+
+    return wrapper
+
+
+def locked_coro(
+    coro: Callable[Concatenate[_AsyncLockable, _P], Awaitable[_T]]
+) -> Callable[Concatenate[_AsyncLockable, _P], Awaitable[_T]]:
+    @wraps(coro)
+    async def wrapper(self: _AsyncLockable, *args: _P.args, **kwargs: _P.kwargs) -> _T:
+        async with self._lock:
+            return await coro(self, *args, **kwargs)  # type: ignore[return-value]
+
+    return wrapper
+
+
+def connected(
+    coro: Callable[Concatenate[MonopriceProtocol, _P], Awaitable[_T]]
+) -> Callable[Concatenate[MonopriceProtocol, _P], Awaitable[_T]]:
+    @wraps(coro)
+    async def wrapper(
+        self: MonopriceProtocol, *args: _P.args, **kwargs: _P.kwargs
+    ) -> _T:
+        await self._connected.wait()
+        return await coro(self, *args, **kwargs)
+
+    return wrapper
+
+
+@dataclass
+class ZoneStatus:
+    zone: int
+    pa: bool
+    power: bool
+    mute: bool
+    do_not_disturb: bool
+    volume: int  # 0 - 38
+    treble: int  # 0 -> -7,  14-> +7
+    bass: int  # 0 -> -7,  14-> +7
+    balance: int  # 00 - left, 10 - center, 20 right
+    source: int
+    keypad: bool
 
     @classmethod
-    def from_string(cls, string: str):
+    def from_string(cls, string: str) -> ZoneStatus | None:
         if not string:
             return None
         match = re.search(ZONE_PATTERN, string)
         if not match:
             return None
-        return ZoneStatus(*[int(m) for m in match.groups()])
+        (
+            zone,
+            pa,
+            power,
+            mute,
+            do_not_disturb,
+            volume,
+            treble,
+            bass,
+            balance,
+            source,
+            keypad,
+        ) = map(int, match.groups())
+        return ZoneStatus(
+            zone,
+            bool(pa),
+            bool(power),
+            bool(mute),
+            bool(do_not_disturb),
+            volume,
+            treble,
+            bass,
+            balance,
+            source,
+            bool(keypad),
+        )
+
+
+class Monoprice:
+    def __init__(self, port_url: str, lock: RLock) -> None:
+        """
+        Monoprice amplifier interface
+        """
+        self._lock = lock
+        self._port = serial.serial_for_url(port_url, do_not_open=True)
+        self._port.baudrate = 9600
+        self._port.stopbits = serial.STOPBITS_ONE
+        self._port.bytesize = serial.EIGHTBITS
+        self._port.parity = serial.PARITY_NONE
+        self._port.timeout = TIMEOUT
+        self._port.write_timeout = TIMEOUT
+        self._port.open()
+
+    def _send_request(self, request: bytes) -> None:
+        """
+        :param request: request that is sent to the monoprice
+        """
+        _LOGGER.debug('Sending "%s"', request)
+        # clear
+        self._port.reset_output_buffer()
+        self._port.reset_input_buffer()
+        # send
+        self._port.write(request)
+        self._port.flush()
+
+    def _process_request(self, request: bytes, skip: int = 0) -> str:
+        """
+        :param request: request that is sent to the monoprice
+        :param skip: number of bytes to skip for end of transmission decoding
+        :return: ascii string returned by monoprice
+        """
+        self._send_request(request)
+        # receive
+        result = bytearray()
+        while True:
+            c = self._port.read(1)
+            if not c:
+                raise serial.SerialTimeoutException(
+                    "Connection timed out! Last received bytes {}".format(
+                        [hex(a) for a in result]
+                    )
+                )
+            result += c
+            if len(result) > skip and result[-LEN_EOL:] == EOL:
+                break
+        ret = bytes(result)
+        _LOGGER.debug('Received "%s"', ret)
+        return ret.decode("ascii")
+
+    def _process_request_sized(self, request: bytes, size: int) -> str:
+        """
+        :param request: request that is sent to the monoprice
+        :param size: number of bytes to read from the device
+        :return: ascii string returned by monoprice
+        """
+        self._send_request(request)
+        # receive
+        result = bytearray()
+        while len(result) < size:
+            c = self._port.read(1)
+            if not c:
+                raise serial.SerialTimeoutException(
+                    "Connection timed out! Last received bytes {}".format(
+                        [hex(a) for a in result]
+                    )
+                )
+            result += c
+        ret = bytes(result)
+        _LOGGER.debug('Received "%s"', ret)
+        return ret.decode("ascii")
+
+    @synchronized
+    def zone_status(self, zone: int) -> ZoneStatus | None:
+        """
+        Get the structure representing the status of the zone
+        :param zone: zone 11..16, 21..26, 31..36
+        :return: status of the zone or None
+        """
+        # Ignore first 6 bytes as they will contain 3 byte command and 3 bytes of EOL
+        return ZoneStatus.from_string(
+            self._process_request(_format_zone_status_request(zone), skip=6)
+        )
+
+    @synchronized
+    def all_zone_status(self, zone: int) -> list[ZoneStatus | None]:
+        # size = (3 byte echo + 3 byte EOL) + 6 * (24 byte data + 3 byte EOL)
+        # Total 168 bytes expected
+        request = self._process_request_sized(
+            _format_zone_status_request(zone), size=168
+        )
+        return [
+            ZoneStatus.from_string(request[6 + i * 27 : 6 + (i + 1) * 27])
+            for i in range(6)
+        ]
+
+    @synchronized
+    def set_power(self, zone: int, power: bool) -> None:
+        """
+        Turn zone on or off
+        :param zone: zone 11..16, 21..26, 31..36
+        :param power: True to turn on, False to turn off
+        """
+        self._process_request(_format_set_power(zone, power))
 
+    @synchronized
+    def set_mute(self, zone: int, mute: bool) -> None:
+        """
+        Mute zone on or off
+        :param zone: zone 11..16, 21..26, 31..36
+        :param mute: True to mute, False to unmute
+        """
+        self._process_request(_format_set_mute(zone, mute))
 
-class Monoprice(object):
-    """
-    Monoprice amplifier interface
-    """
+    @synchronized
+    def set_volume(self, zone: int, volume: int) -> None:
+        """
+        Set volume for zone
+        :param zone: zone 11..16, 21..26, 31..36
+        :param volume: integer from 0 to 38 inclusive
+        """
+        self._process_request(_format_set_volume(zone, volume))
+
+    @synchronized
+    def set_treble(self, zone: int, treble: int) -> None:
+        """
+        Set treble for zone
+        :param zone: zone 11..16, 21..26, 31..36
+        :param treble: integer from 0 to 14 inclusive, where 0 is -7 treble and 14 is +7
+        """
+        self._process_request(_format_set_treble(zone, treble))
+
+    @synchronized
+    def set_bass(self, zone: int, bass: int) -> None:
+        """
+        Set bass for zone
+        :param zone: zone 11..16, 21..26, 31..36
+        :param bass: integer from 0 to 14 inclusive, where 0 is -7 bass and 14 is +7
+        """
+        self._process_request(_format_set_bass(zone, bass))
+
+    @synchronized
+    def set_balance(self, zone: int, balance: int) -> None:
+        """
+        Set balance for zone
+        :param zone: zone 11..16, 21..26, 31..36
+        :param balance: integer from 0 to 20 inclusive, where 0 is -10(left), 0 is center and 20 is +10 (right)
+        """
+        self._process_request(_format_set_balance(zone, balance))
+
+    @synchronized
+    def set_source(self, zone: int, source: int) -> None:
+        """
+        Set source for zone
+        :param zone: zone 11..16, 21..26, 31..36
+        :param source: integer from 0 to 6 inclusive
+        """
+        self._process_request(_format_set_source(zone, source))
 
-    def zone_status(self, zone: int):
+    @synchronized
+    def restore_zone(self, status: ZoneStatus) -> None:
+        """
+        Restores zone to it's previous state
+        :param status: zone state to restore
+        """
+        self.set_power(status.zone, status.power)
+        self.set_mute(status.zone, status.mute)
+        self.set_volume(status.zone, status.volume)
+        self.set_treble(status.zone, status.treble)
+        self.set_bass(status.zone, status.bass)
+        self.set_balance(status.zone, status.balance)
+        self.set_source(status.zone, status.source)
+
+
+class MonopriceAsync:
+    def __init__(
+        self, monoprice_protocol: MonopriceProtocol, lock: asyncio.Lock
+    ) -> None:
+        """
+        Async Monoprice amplifier interface
+        """
+        self._protocol = monoprice_protocol
+        self._lock = lock
+
+    @locked_coro
+    async def zone_status(self, zone: int) -> ZoneStatus | None:
         """
         Get the structure representing the status of the zone
         :param zone: zone 11..16, 21..26, 31..36
         :return: status of the zone or None
         """
-        raise NotImplemented()
-
-    def set_power(self, zone: int, power: bool):
+        # Ignore first 6 bytes as they will contain 3 byte command and 3 bytes of EOL
+        string = await self._protocol.send(_format_zone_status_request(zone), skip=6)
+        return ZoneStatus.from_string(string)
+
+    @locked_coro
+    async def all_zone_status(self, zone: int) -> list[ZoneStatus | None]:
+        # size = (3 byte echo + 3 byte EOL) + 6 * (24 byte data + 3 byte EOL)
+        # Total 168 bytes expected
+        string = await self._protocol.send_sized(
+            _format_zone_status_request(zone), size=168
+        )
+        return [
+            ZoneStatus.from_string(string[6 + i * 27 : 6 + (i + 1) * 27])
+            for i in range(6)
+        ]
+
+    @locked_coro
+    async def set_power(self, zone: int, power: bool) -> None:
         """
         Turn zone on or off
         :param zone: zone 11..16, 21..26, 31..36
         :param power: True to turn on, False to turn off
         """
-        raise NotImplemented()
+        await self._protocol.send(_format_set_power(zone, power))
 
-    def set_mute(self, zone: int, mute: bool):
+    @locked_coro
+    async def set_mute(self, zone: int, mute: bool) -> None:
         """
         Mute zone on or off
         :param zone: zone 11..16, 21..26, 31..36
         :param mute: True to mute, False to unmute
         """
-        raise NotImplemented()
+        await self._protocol.send(_format_set_mute(zone, mute))
 
-    def set_volume(self, zone: int, volume: int):
+    @locked_coro
+    async def set_volume(self, zone: int, volume: int) -> None:
         """
         Set volume for zone
         :param zone: zone 11..16, 21..26, 31..36
         :param volume: integer from 0 to 38 inclusive
         """
-        raise NotImplemented()
+        await self._protocol.send(_format_set_volume(zone, volume))
 
-    def set_treble(self, zone: int, treble: int):
+    @locked_coro
+    async def set_treble(self, zone: int, treble: int) -> None:
         """
         Set treble for zone
         :param zone: zone 11..16, 21..26, 31..36
         :param treble: integer from 0 to 14 inclusive, where 0 is -7 treble and 14 is +7
         """
-        raise NotImplemented()
+        await self._protocol.send(_format_set_treble(zone, treble))
 
-    def set_bass(self, zone: int, bass: int):
+    @locked_coro
+    async def set_bass(self, zone: int, bass: int) -> None:
         """
         Set bass for zone
         :param zone: zone 11..16, 21..26, 31..36
         :param bass: integer from 0 to 14 inclusive, where 0 is -7 bass and 14 is +7
         """
-        raise NotImplemented()
+        await self._protocol.send(_format_set_bass(zone, bass))
 
-    def set_balance(self, zone: int, balance: int):
+    @locked_coro
+    async def set_balance(self, zone: int, balance: int) -> None:
         """
         Set balance for zone
         :param zone: zone 11..16, 21..26, 31..36
         :param balance: integer from 0 to 20 inclusive, where 0 is -10(left), 0 is center and 20 is +10 (right)
         """
-        raise NotImplemented()
+        await self._protocol.send(_format_set_balance(zone, balance))
 
-    def set_source(self, zone: int, source: int):
+    @locked_coro
+    async def set_source(self, zone: int, source: int) -> None:
         """
         Set source for zone
         :param zone: zone 11..16, 21..26, 31..36
         :param source: integer from 0 to 6 inclusive
         """
-        raise NotImplemented()
+        await self._protocol.send(_format_set_source(zone, source))
 
-    def restore_zone(self, status: ZoneStatus):
+    @locked_coro
+    async def restore_zone(self, status: ZoneStatus) -> None:
         """
         Restores zone to it's previous state
         :param status: zone state to restore
         """
-        raise NotImplemented()
+        await self._protocol.send(_format_set_power(status.zone, status.power))
+        await self._protocol.send(_format_set_mute(status.zone, status.mute))
+        await self._protocol.send(_format_set_volume(status.zone, status.volume))
+        await self._protocol.send(_format_set_treble(status.zone, status.treble))
+        await self._protocol.send(_format_set_bass(status.zone, status.bass))
+        await self._protocol.send(_format_set_balance(status.zone, status.balance))
+        await self._protocol.send(_format_set_source(status.zone, status.source))
+
+
+class MonopriceProtocol(asyncio.Protocol):
+    def __init__(self) -> None:
+        super().__init__()
+        self._lock = asyncio.Lock()
+        self._tasks: set[asyncio.Task[None]] = set()
+        self._transport: SerialTransport = None
+        self._connected = asyncio.Event()
+        self.q: asyncio.Queue[bytes] = asyncio.Queue()
+
+    def connection_made(self, transport: SerialTransport) -> None:
+        self._transport = transport
+        self._connected.set()
+        _LOGGER.debug("port opened %s", self._transport)
+
+    def data_received(self, data: bytes) -> None:
+        task = asyncio.create_task(self.q.put(data))
+        self._tasks.add(task)
+        task.add_done_callback(self._tasks.discard)
+
+    @connected
+    @locked_coro
+    async def send(self, request: bytes, skip: int = 0) -> str:
+        result = bytearray()
+        self._transport.serial.reset_output_buffer()
+        self._transport.serial.reset_input_buffer()
+        while not self.q.empty():
+            self.q.get_nowait()
+        self._transport.write(request)
+        try:
+            while len(result) <= skip or result[-LEN_EOL:] != EOL:
+                result += await asyncio.wait_for(self.q.get(), TIMEOUT)
+        except asyncio.TimeoutError:
+            _LOGGER.error(
+                "Timeout during receiving response for command '%s', received='%s'",
+                request,
+                result,
+            )
+            raise
+        ret = bytes(result)
+        _LOGGER.debug('Received "%s"', ret)
+        return ret.decode("ascii")
+
+    @connected
+    @locked_coro
+    async def send_sized(self, request: bytes, size: int) -> str:
+        self._transport.serial.reset_output_buffer()
+        self._transport.serial.reset_input_buffer()
+        while not self.q.empty():
+            self.q.get_nowait()
+        self._transport.write(request)
+
+        result = bytearray()
+        try:
+            while len(result) < size:
+                result += await asyncio.wait_for(self.q.get(), TIMEOUT)
+        except asyncio.TimeoutError:
+            _LOGGER.error(
+                "Timeout during receiving response for command '%s', received='%s'",
+                request,
+                result,
+            )
+            raise
+        ret = bytes(result)
+        _LOGGER.debug('Received "%s"', ret)
+        return ret.decode("ascii")
 
 
 # Helpers
 
+
 def _format_zone_status_request(zone: int) -> bytes:
-    return '?{}\r'.format(zone).encode()
+    return "?{}\r".format(zone).encode()
 
 
 def _format_set_power(zone: int, power: bool) -> bytes:
-    return '<{}PR{}\r'.format(zone, '01' if power else '00').encode()
+    return "<{}PR{}\r".format(zone, "01" if power else "00").encode()
 
 
 def _format_set_mute(zone: int, mute: bool) -> bytes:
-    return '<{}MU{}\r'.format(zone, '01' if mute else '00').encode()
+    return "<{}MU{}\r".format(zone, "01" if mute else "00").encode()
 
 
 def _format_set_volume(zone: int, volume: int) -> bytes:
     volume = int(max(0, min(volume, 38)))
-    return '<{}VO{:02}\r'.format(zone, volume).encode()
+    return "<{}VO{:02}\r".format(zone, volume).encode()
 
 
 def _format_set_treble(zone: int, treble: int) -> bytes:
     treble = int(max(0, min(treble, 14)))
-    return '<{}TR{:02}\r'.format(zone, treble).encode()
+    return "<{}TR{:02}\r".format(zone, treble).encode()
 
 
 def _format_set_bass(zone: int, bass: int) -> bytes:
     bass = int(max(0, min(bass, 14)))
-    return '<{}BS{:02}\r'.format(zone, bass).encode()
+    return "<{}BS{:02}\r".format(zone, bass).encode()
 
 
 def _format_set_balance(zone: int, balance: int) -> bytes:
     balance = max(0, min(balance, 20))
-    return '<{}BL{:02}\r'.format(zone, balance).encode()
+    return "<{}BL{:02}\r".format(zone, balance).encode()
 
 
 def _format_set_source(zone: int, source: int) -> bytes:
     source = int(max(1, min(source, 6)))
-    return '<{}CH{:02}\r'.format(zone, source).encode()
+    return "<{}CH{:02}\r".format(zone, source).encode()
 
 
-def get_monoprice(port_url):
+def get_monoprice(port_url: str) -> Monoprice:
     """
     Return synchronous version of Monoprice interface
     :param port_url: serial port, i.e. '/dev/ttyUSB0'
@@ -175,99 +513,10 @@ def get_monoprice(port_url):
 
     lock = RLock()
 
-    def synchronized(func):
-        @wraps(func)
-        def wrapper(*args, **kwargs):
-            with lock:
-                return func(*args, **kwargs)
-        return wrapper
-
-    class MonopriceSync(Monoprice):
-        def __init__(self, port_url):
-            self._port = serial.serial_for_url(port_url, do_not_open=True)
-            self._port.baudrate = 9600
-            self._port.stopbits = serial.STOPBITS_ONE
-            self._port.bytesize = serial.EIGHTBITS
-            self._port.parity = serial.PARITY_NONE
-            self._port.timeout = TIMEOUT
-            self._port.write_timeout = TIMEOUT
-            self._port.open()
-
-        def _process_request(self, request: bytes, skip=0):
-            """
-            :param request: request that is sent to the monoprice
-            :param skip: number of bytes to skip for end of transmission decoding
-            :return: ascii string returned by monoprice
-            """
-            _LOGGER.debug('Sending "%s"', request)
-            # clear
-            self._port.reset_output_buffer()
-            self._port.reset_input_buffer()
-            # send
-            self._port.write(request)
-            self._port.flush()
-            # receive
-            result = bytearray()
-            while True:
-                c = self._port.read(1)
-                if not c:
-                    raise serial.SerialTimeoutException(
-                        'Connection timed out! Last received bytes {}'.format([hex(a) for a in result]))
-                result += c
-                if len(result) > skip and result[-LEN_EOL:] == EOL:
-                    break
-            ret = bytes(result)
-            _LOGGER.debug('Received "%s"', ret)
-            return ret.decode('ascii')
-
-        @synchronized
-        def zone_status(self, zone: int):
-            # Ignore first 6 bytes as they will contain 3 byte command and 3 bytes of EOL
-            return ZoneStatus.from_string(self._process_request(_format_zone_status_request(zone), skip=6))
-
-        @synchronized
-        def set_power(self, zone: int, power: bool):
-            self._process_request(_format_set_power(zone, power))
-
-        @synchronized
-        def set_mute(self, zone: int, mute: bool):
-            self._process_request(_format_set_mute(zone, mute))
-
-        @synchronized
-        def set_volume(self, zone: int, volume: int):
-            self._process_request(_format_set_volume(zone, volume))
-
-        @synchronized
-        def set_treble(self, zone: int, treble: int):
-            self._process_request(_format_set_treble(zone, treble))
-
-        @synchronized
-        def set_bass(self, zone: int, bass: int):
-            self._process_request(_format_set_bass(zone, bass))
-
-        @synchronized
-        def set_balance(self, zone: int, balance: int):
-            self._process_request(_format_set_balance(zone, balance))
-
-        @synchronized
-        def set_source(self, zone: int, source: int):
-            self._process_request(_format_set_source(zone, source))
-
-        @synchronized
-        def restore_zone(self, status: ZoneStatus):
-            self.set_power(status.zone, status.power)
-            self.set_mute(status.zone, status.mute)
-            self.set_volume(status.zone, status.volume)
-            self.set_treble(status.zone, status.treble)
-            self.set_bass(status.zone, status.bass)
-            self.set_balance(status.zone, status.balance)
-            self.set_source(status.zone, status.source)
-
-    return MonopriceSync(port_url)
-
-
-@asyncio.coroutine
-def get_async_monoprice(port_url, loop):
+    return Monoprice(port_url, lock)
+
+
+async def get_async_monoprice(port_url: str) -> MonopriceAsync:
     """
     Return asynchronous version of Monoprice interface
     :param port_url: serial port, i.e. '/dev/ttyUSB0'
@@ -276,110 +525,8 @@ def get_async_monoprice(port_url, loop):
 
     lock = asyncio.Lock()
 
-    def locked_coro(coro):
-        @asyncio.coroutine
-        @wraps(coro)
-        def wrapper(*args, **kwargs):
-            with (yield from lock):
-                return (yield from coro(*args, **kwargs))
-        return wrapper
-
-    class MonopriceAsync(Monoprice):
-        def __init__(self, monoprice_protocol):
-            self._protocol = monoprice_protocol
-
-        @locked_coro
-        @asyncio.coroutine
-        def zone_status(self, zone: int):
-            # Ignore first 6 bytes as they will contain 3 byte command and 3 bytes of EOL
-            string = yield from self._protocol.send(_format_zone_status_request(zone), skip=6)
-            return ZoneStatus.from_string(string)
-
-        @locked_coro
-        @asyncio.coroutine
-        def set_power(self, zone: int, power: bool):
-            yield from self._protocol.send(_format_set_power(zone, power))
-
-        @locked_coro
-        @asyncio.coroutine
-        def set_mute(self, zone: int, mute: bool):
-            yield from self._protocol.send(_format_set_mute(zone, mute))
-
-        @locked_coro
-        @asyncio.coroutine
-        def set_volume(self, zone: int, volume: int):
-            yield from self._protocol.send(_format_set_volume(zone, volume))
-
-        @locked_coro
-        @asyncio.coroutine
-        def set_treble(self, zone: int, treble: int):
-            yield from self._protocol.send(_format_set_treble(zone, treble))
-
-        @locked_coro
-        @asyncio.coroutine
-        def set_bass(self, zone: int, bass: int):
-            yield from self._protocol.send(_format_set_bass(zone, bass))
-
-        @locked_coro
-        @asyncio.coroutine
-        def set_balance(self, zone: int, balance: int):
-            yield from self._protocol.send(_format_set_balance(zone, balance))
-
-        @locked_coro
-        @asyncio.coroutine
-        def set_source(self, zone: int, source: int):
-            yield from self._protocol.send(_format_set_source(zone, source))
-
-        @locked_coro
-        @asyncio.coroutine
-        def restore_zone(self, status: ZoneStatus):
-            yield from self._protocol.send(_format_set_power(status.zone, status.power))
-            yield from self._protocol.send(_format_set_mute(status.zone, status.mute))
-            yield from self._protocol.send(_format_set_volume(status.zone, status.volume))
-            yield from self._protocol.send(_format_set_treble(status.zone, status.treble))
-            yield from self._protocol.send(_format_set_bass(status.zone, status.bass))
-            yield from self._protocol.send(_format_set_balance(status.zone, status.balance))
-            yield from self._protocol.send(_format_set_source(status.zone, status.source))
-
-    class MonopriceProtocol(asyncio.Protocol):
-        def __init__(self, loop):
-            super().__init__()
-            self._loop = loop
-            self._lock = asyncio.Lock()
-            self._transport = None
-            self._connected = asyncio.Event(loop=loop)
-            self.q = asyncio.Queue(loop=loop)
-
-        def connection_made(self, transport):
-            self._transport = transport
-            self._connected.set()
-            _LOGGER.debug('port opened %s', self._transport)
-
-        def data_received(self, data):
-            asyncio.ensure_future(self.q.put(data), loop=self._loop)
-
-        @asyncio.coroutine
-        def send(self, request: bytes, skip=0):
-            yield from self._connected.wait()
-            result = bytearray()
-            # Only one transaction at a time
-            with (yield from self._lock):
-                self._transport.serial.reset_output_buffer()
-                self._transport.serial.reset_input_buffer()
-                while not self.q.empty():
-                    self.q.get_nowait()
-                self._transport.write(request)
-                try:
-                    while True:
-                        result += yield from asyncio.wait_for(self.q.get(), TIMEOUT, loop=self._loop)
-                        if len(result) > skip and result[-LEN_EOL:] == EOL:
-                            ret = bytes(result)
-                            _LOGGER.debug('Received "%s"', ret)
-                            return ret.decode('ascii')
-                except asyncio.TimeoutError:
-                    _LOGGER.error("Timeout during receiving response for command '%s', received='%s'", request, result)
-                    raise
-
-    _, protocol = yield from create_serial_connection(loop, functools.partial(MonopriceProtocol, loop),
-                                                      port_url, baudrate=9600)
-    return MonopriceAsync(protocol)
+    loop = asyncio.get_running_loop()
+    _, protocol = await create_serial_connection(
+        loop, MonopriceProtocol, port_url, baudrate=9600
+    )
+    return MonopriceAsync(protocol, lock)
diff --git a/pymonoprice/py.typed b/pymonoprice/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/setup.cfg b/setup.cfg
index b88034e..d12ada1 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,2 +1,51 @@
 [metadata]
-description-file = README.md
+name = pymonoprice
+url = https://github.com/etsinko/pymonoprice
+license = MIT
+license_file = LICENSE.txt
+description = Python API for talking to Monoprice 6-zone amplifier
+long_description = file: README.md
+long_description_content_type = text/x-markdown
+author = Egor Tsinko
+author_email = etsinko@gmail.com
+classifiers =
+  Development Status :: 4 - Beta
+  Programming Language :: Python :: 3.7
+  Programming Language :: Python :: 3.8
+  Programming Language :: Python :: 3.9
+  Programming Language :: Python :: 3.10
+
+[options]
+packages =
+  pymonoprice
+python_requires >= 3.7
+zip_safe = True
+install_requires =
+  pyserial>=3.4
+  pyserial-asyncio>=0.4
+
+[options.package_data]
+pymonoprice = py.typed
+
+[mypy]
+python_version = 3.10
+disallow_any_decorated = true
+disallow_any_explicit = true
+disallow_any_generics = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+no_implicit_optional = true
+show_error_codes = true
+strict_equality = true
+warn_no_return = true
+warn_redundant_casts = true
+warn_unreachable = true
+warn_unused_configs = True
+warn_unused_ignores = true
+[mypy-serial]
+ignore_missing_imports = True
+[mypy-serial_asyncio]
+ignore_missing_imports = True
diff --git a/setup.py b/setup.py
index f0c3a8f..3cb66c2 100755
--- a/setup.py
+++ b/setup.py
@@ -10,41 +10,13 @@ try:
 except ImportError:
     from distutils.core import setup
 
+
 if sys.argv[-1] == 'publish':
     os.system('python setup.py sdist upload')
     sys.exit()
 
-license = """
-MIT License
-Copyright (c) 2017 Egor Tsinko
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-"""
 
-setup(name='pymonoprice',
-      version=VERSION,
-      description='Python API for talking to Monoprice 6-zone amplifier',
-      url='https://github.com/etsinko/pymonoprice',
-      download_url='https://github.com/etsinko/pymonoprice/archive/{}.tar.gz'.format(VERSION),
-      author='Egor Tsinko',
-      author_email='etsinko@gmail.com',
-      license='MIT',
-      install_requires=['pyserial>=3.4','pyserial-asyncio>=0.4'],
-      packages=['pymonoprice'],
-      classifiers=['Development Status :: 4 - Beta',
-                   'Programming Language :: Python :: 3.4',
-                   'Programming Language :: Python :: 3.5'],
-      zip_safe=True)
+setup(
+    version=VERSION,
+    download_url='https://github.com/etsinko/pymonoprice/archive/{}.tar.gz'.format(VERSION),
+)


Test patch:
diff --git a/tests/test_monoprice.py b/tests/test_monoprice.py
index d386c6b..731d852 100644
--- a/tests/test_monoprice.py
+++ b/tests/test_monoprice.py
@@ -149,7 +149,7 @@ class TestAsyncMonoprice(TestMonoprice):
     def setUp(self):
         self.responses = {}
         loop = asyncio.get_event_loop()
-        monoprice = loop.run_until_complete(get_async_monoprice(create_dummy_port(self.responses), loop))
+        monoprice = loop.run_until_complete(get_async_monoprice(create_dummy_port(self.responses)))
 
         # Dummy monoprice that converts async to sync
         class DummyMonoprice():
@@ -164,4 +164,4 @@ class TestAsyncMonoprice(TestMonoprice):
             self.monoprice.set_source(3, 3)
 
 if __name__ == '__main__':
-    unittest.main()
\ No newline at end of file
+    unittest.main()