aio-ld2410 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aio_ld2410-0.1.0/LICENSE +22 -0
- aio_ld2410-0.1.0/MANIFEST.in +4 -0
- aio_ld2410-0.1.0/PKG-INFO +87 -0
- aio_ld2410-0.1.0/README.md +61 -0
- aio_ld2410-0.1.0/aio_ld2410/__init__.py +40 -0
- aio_ld2410-0.1.0/aio_ld2410/exception.py +17 -0
- aio_ld2410-0.1.0/aio_ld2410/ld2410.py +408 -0
- aio_ld2410-0.1.0/aio_ld2410/models.py +150 -0
- aio_ld2410-0.1.0/aio_ld2410/protocol/__init__.py +45 -0
- aio_ld2410-0.1.0/aio_ld2410/protocol/command.py +243 -0
- aio_ld2410-0.1.0/aio_ld2410/protocol/frame.py +66 -0
- aio_ld2410-0.1.0/aio_ld2410/protocol/report.py +58 -0
- aio_ld2410-0.1.0/aio_ld2410/py.typed +0 -0
- aio_ld2410-0.1.0/aio_ld2410/stream.py +82 -0
- aio_ld2410-0.1.0/aio_ld2410/version.py +1 -0
- aio_ld2410-0.1.0/aio_ld2410.egg-info/PKG-INFO +87 -0
- aio_ld2410-0.1.0/aio_ld2410.egg-info/SOURCES.txt +23 -0
- aio_ld2410-0.1.0/aio_ld2410.egg-info/dependency_links.txt +1 -0
- aio_ld2410-0.1.0/aio_ld2410.egg-info/requires.txt +6 -0
- aio_ld2410-0.1.0/aio_ld2410.egg-info/top_level.txt +1 -0
- aio_ld2410-0.1.0/pyproject.toml +170 -0
- aio_ld2410-0.1.0/setup.cfg +4 -0
- aio_ld2410-0.1.0/tests/test_ld2410.py +363 -0
- aio_ld2410-0.1.0/tests/test_stream.py +50 -0
- aio_ld2410-0.1.0/tests/test_version.py +7 -0
aio_ld2410-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Mòrian
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: aio_ld2410
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Asynchonous library for the HiLink LD2410 presence detector module
|
|
5
|
+
Author-email: Romain Bezut <morian@xdec.net>
|
|
6
|
+
Classifier: Intended Audience :: Developers
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Topic :: Home Automation
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Human Machine Interfaces
|
|
15
|
+
Classifier: Topic :: Software Development :: Embedded Systems
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Topic :: System :: Hardware
|
|
18
|
+
Classifier: Topic :: Terminals :: Serial
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: async-timeout>=4; python_version < "3.11"
|
|
23
|
+
Requires-Dist: construct<3.0,>=2.10
|
|
24
|
+
Requires-Dist: dacite<2.0,>=1.8
|
|
25
|
+
Requires-Dist: pyserial-asyncio-fast<1.0,>=0.14
|
|
26
|
+
|
|
27
|
+
Python asyncio LD2410 Library
|
|
28
|
+
=============================
|
|
29
|
+
|
|
30
|
+
`aio_ld2410` allows you to interact with the LD2410 radar sensors from Hi-Link using asyncio.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
## How to install
|
|
34
|
+
|
|
35
|
+
This package requires python 3.9 or later, and depends on the following packages:
|
|
36
|
+
- [construct](https://pypi.org/project/construct/) for binary serialization/deserialization
|
|
37
|
+
- [dacite](https://pypi.org/project/dacite/) to build dataclasses with a minimal footprint
|
|
38
|
+
- [pyserial-asyncio-fast](https://pypi.org/project/pyserial-asyncio-fast/) for serial async communication
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
### Install from pip
|
|
42
|
+
|
|
43
|
+
```console
|
|
44
|
+
$ pip install aio-ld2410
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Install for development
|
|
48
|
+
```console
|
|
49
|
+
$ python -m venv venv
|
|
50
|
+
$ source venv/bin/activate
|
|
51
|
+
$ make install
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Implementation references
|
|
55
|
+
|
|
56
|
+
User manual and serial communication protocol can be found on
|
|
57
|
+
[Hi-Link Website](https://www.hlktech.net/index.php?id=1095).
|
|
58
|
+
|
|
59
|
+
This implementation was originally based on `LD2410C Serial communication protocol V1.00.pdf`.
|
|
60
|
+
|
|
61
|
+
Auxiliary commands were implemented based on `LD2410B Serial communication protocol V1.06.pdf`,
|
|
62
|
+
translated from Chinese, as mentioned in the following comment:
|
|
63
|
+
- https://github.com/esphome/feature-requests/issues/2156#issuecomment-1472962509
|
|
64
|
+
|
|
65
|
+
Note that some commands may not be available depending on your device model and firmware version.
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
## Example usage
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from aio_ld2410 import LD2410
|
|
72
|
+
|
|
73
|
+
async def async_main():
|
|
74
|
+
async with LD2410('/dev/ttyUSB0', baudrate=256000) as device:
|
|
75
|
+
async with device.configure():
|
|
76
|
+
ver = await device.get_firmware_version()
|
|
77
|
+
print(f'Running with firmware {ver}')
|
|
78
|
+
|
|
79
|
+
# Ask for engineering (advanced) reports as well.
|
|
80
|
+
await device.set_engineering_mode(True)
|
|
81
|
+
|
|
82
|
+
# Reports are generated every 100ms.
|
|
83
|
+
async for report in device.get_reports():
|
|
84
|
+
print(report)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Full documentation is not written yet.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Python asyncio LD2410 Library
|
|
2
|
+
=============================
|
|
3
|
+
|
|
4
|
+
`aio_ld2410` allows you to interact with the LD2410 radar sensors from Hi-Link using asyncio.
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
## How to install
|
|
8
|
+
|
|
9
|
+
This package requires python 3.9 or later, and depends on the following packages:
|
|
10
|
+
- [construct](https://pypi.org/project/construct/) for binary serialization/deserialization
|
|
11
|
+
- [dacite](https://pypi.org/project/dacite/) to build dataclasses with a minimal footprint
|
|
12
|
+
- [pyserial-asyncio-fast](https://pypi.org/project/pyserial-asyncio-fast/) for serial async communication
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Install from pip
|
|
16
|
+
|
|
17
|
+
```console
|
|
18
|
+
$ pip install aio-ld2410
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Install for development
|
|
22
|
+
```console
|
|
23
|
+
$ python -m venv venv
|
|
24
|
+
$ source venv/bin/activate
|
|
25
|
+
$ make install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Implementation references
|
|
29
|
+
|
|
30
|
+
User manual and serial communication protocol can be found on
|
|
31
|
+
[Hi-Link Website](https://www.hlktech.net/index.php?id=1095).
|
|
32
|
+
|
|
33
|
+
This implementation was originally based on `LD2410C Serial communication protocol V1.00.pdf`.
|
|
34
|
+
|
|
35
|
+
Auxiliary commands were implemented based on `LD2410B Serial communication protocol V1.06.pdf`,
|
|
36
|
+
translated from Chinese, as mentioned in the following comment:
|
|
37
|
+
- https://github.com/esphome/feature-requests/issues/2156#issuecomment-1472962509
|
|
38
|
+
|
|
39
|
+
Note that some commands may not be available depending on your device model and firmware version.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
## Example usage
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from aio_ld2410 import LD2410
|
|
46
|
+
|
|
47
|
+
async def async_main():
|
|
48
|
+
async with LD2410('/dev/ttyUSB0', baudrate=256000) as device:
|
|
49
|
+
async with device.configure():
|
|
50
|
+
ver = await device.get_firmware_version()
|
|
51
|
+
print(f'Running with firmware {ver}')
|
|
52
|
+
|
|
53
|
+
# Ask for engineering (advanced) reports as well.
|
|
54
|
+
await device.set_engineering_mode(True)
|
|
55
|
+
|
|
56
|
+
# Reports are generated every 100ms.
|
|
57
|
+
async for report in device.get_reports():
|
|
58
|
+
print(report)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Full documentation is not written yet.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from .exception import AioLd2410Error, CommandError, CommandStatusError, ModuleRestartedError
|
|
2
|
+
from .ld2410 import LD2410
|
|
3
|
+
from .models import (
|
|
4
|
+
AuxiliaryControlConfig,
|
|
5
|
+
AuxiliaryControlStatus,
|
|
6
|
+
ConfigModeStatus,
|
|
7
|
+
FirmwareVersion,
|
|
8
|
+
GateSensitivityConfig,
|
|
9
|
+
ParametersConfig,
|
|
10
|
+
ParametersStatus,
|
|
11
|
+
ReportBasicStatus,
|
|
12
|
+
ReportEngineeringStatus,
|
|
13
|
+
ReportStatus,
|
|
14
|
+
)
|
|
15
|
+
from .protocol import AuxiliaryControl, BaudRateIndex, OutPinLevel, TargetStatus
|
|
16
|
+
from .version import version
|
|
17
|
+
|
|
18
|
+
__version__ = version
|
|
19
|
+
__all__ = [
|
|
20
|
+
'AioLd2410Error',
|
|
21
|
+
'AuxiliaryControl',
|
|
22
|
+
'AuxiliaryControlConfig',
|
|
23
|
+
'AuxiliaryControlStatus',
|
|
24
|
+
'BaudRateIndex',
|
|
25
|
+
'CommandError',
|
|
26
|
+
'CommandStatusError',
|
|
27
|
+
'ConfigModeStatus',
|
|
28
|
+
'FirmwareVersion',
|
|
29
|
+
'GateSensitivityConfig',
|
|
30
|
+
'LD2410',
|
|
31
|
+
'ModuleRestartedError',
|
|
32
|
+
'OutPinLevel',
|
|
33
|
+
'ParametersConfig',
|
|
34
|
+
'ParametersStatus',
|
|
35
|
+
'ReportBasicStatus',
|
|
36
|
+
'ReportEngineeringStatus',
|
|
37
|
+
'ReportStatus',
|
|
38
|
+
'TargetStatus',
|
|
39
|
+
'version',
|
|
40
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class AioLd2410Error(Exception):
|
|
2
|
+
"""Base error for exceptions generated by this library."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CommandError(AioLd2410Error):
|
|
6
|
+
"""An error raised after a command was sent."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CommandStatusError(CommandError):
|
|
10
|
+
"""An error raised after a failure status was received."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ModuleRestartedError(BaseException):
|
|
14
|
+
"""Raised when the module is restarting.
|
|
15
|
+
|
|
16
|
+
Do not catch this exception, it is used by the configuration context.
|
|
17
|
+
"""
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import copy
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from asyncio import StreamReader, StreamWriter
|
|
8
|
+
from contextlib import AsyncExitStack, asynccontextmanager, suppress
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, cast
|
|
10
|
+
|
|
11
|
+
from construct import Container
|
|
12
|
+
from serial_asyncio_fast import open_serial_connection
|
|
13
|
+
|
|
14
|
+
from .exception import CommandError, CommandStatusError, ModuleRestartedError
|
|
15
|
+
from .models import (
|
|
16
|
+
AuxiliaryControlConfig,
|
|
17
|
+
AuxiliaryControlStatus,
|
|
18
|
+
ConfigModeStatus,
|
|
19
|
+
FirmwareVersion,
|
|
20
|
+
GateSensitivityConfig,
|
|
21
|
+
ParametersConfig,
|
|
22
|
+
ParametersStatus,
|
|
23
|
+
ReportStatus,
|
|
24
|
+
container_to_model,
|
|
25
|
+
)
|
|
26
|
+
from .protocol import (
|
|
27
|
+
BaudRateIndex,
|
|
28
|
+
Command,
|
|
29
|
+
CommandCode,
|
|
30
|
+
CommandFrame,
|
|
31
|
+
FrameType,
|
|
32
|
+
Reply,
|
|
33
|
+
ReplyStatus,
|
|
34
|
+
Report,
|
|
35
|
+
ResolutionIndex,
|
|
36
|
+
)
|
|
37
|
+
from .stream import FrameStream
|
|
38
|
+
|
|
39
|
+
if sys.version_info >= (3, 11): # pragma: no branch
|
|
40
|
+
from asyncio import timeout
|
|
41
|
+
else: # pragma: no cover
|
|
42
|
+
from async_timeout import timeout # type: ignore[import-not-found]
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from collections.abc import AsyncIterator, Awaitable, Mapping
|
|
46
|
+
from types import TracebackType
|
|
47
|
+
|
|
48
|
+
from typing_extensions import Concatenate, Never, ParamSpec, Self, TypeAlias, Unpack
|
|
49
|
+
|
|
50
|
+
_P = ParamSpec('_P')
|
|
51
|
+
_T = TypeVar('_T')
|
|
52
|
+
|
|
53
|
+
_ReplyType: TypeAlias = Container[Any]
|
|
54
|
+
logger = logging.getLogger(__package__)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def configuration(
|
|
58
|
+
func: Callable[Concatenate[LD2410, _P], Awaitable[_T]],
|
|
59
|
+
) -> Callable[Concatenate[LD2410, _P], Awaitable[_T]]:
|
|
60
|
+
"""Decorate an async method so we can check for the configuration context."""
|
|
61
|
+
if not asyncio.iscoroutinefunction(func):
|
|
62
|
+
raise RuntimeError('@configuration decorator is only suitable for async methods.')
|
|
63
|
+
|
|
64
|
+
async def _check_config_context(
|
|
65
|
+
self: LD2410,
|
|
66
|
+
*args: _P.args,
|
|
67
|
+
**kwargs: _P.kwargs,
|
|
68
|
+
) -> _T:
|
|
69
|
+
if not self.configuring:
|
|
70
|
+
raise CommandError('This method requires a configuration context')
|
|
71
|
+
return await func(self, *args, **kwargs)
|
|
72
|
+
|
|
73
|
+
return _check_config_context
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LD2410:
|
|
77
|
+
"""Client of the LD2410 sensor."""
|
|
78
|
+
|
|
79
|
+
DEFAULT_COMMAND_TIMEOUT = 2.0
|
|
80
|
+
DEFAULT_BAUDRATE = 256000
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
device: str,
|
|
85
|
+
*,
|
|
86
|
+
baudrate: int = DEFAULT_BAUDRATE,
|
|
87
|
+
command_timeout: float | None = DEFAULT_COMMAND_TIMEOUT,
|
|
88
|
+
read_bufsize: int | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Create a new client the supplied device."""
|
|
91
|
+
self._baudrate = baudrate
|
|
92
|
+
self._command_timeout = command_timeout
|
|
93
|
+
self._device = device
|
|
94
|
+
self._config_lock = asyncio.Lock()
|
|
95
|
+
self._read_bufsize = read_bufsize
|
|
96
|
+
self._report = None # type: ReportStatus | None
|
|
97
|
+
self._report_condition = asyncio.Condition()
|
|
98
|
+
self._request_lock = asyncio.Lock()
|
|
99
|
+
self._connected = False
|
|
100
|
+
self._context = None # type: AsyncExitStack | None
|
|
101
|
+
self._replies = None # type: asyncio.Queue[_ReplyType | None] | None
|
|
102
|
+
self._restarted = False
|
|
103
|
+
self._rdtask = None # type: asyncio.Task[None] | None
|
|
104
|
+
self._writer = None # type: StreamWriter | None
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def configuring(self) -> bool:
|
|
108
|
+
"""Tell whether configuration mode is currently entered."""
|
|
109
|
+
return bool(not self._restarted and self._config_lock.locked())
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def connected(self) -> bool:
|
|
113
|
+
"""Tell whether we are still connected and listening to frames."""
|
|
114
|
+
return bool(self._connected and self._writer is not None and self._replies is not None)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def entered(self) -> bool:
|
|
118
|
+
"""Tell whether the context manager is already entered."""
|
|
119
|
+
return bool(self._context is not None)
|
|
120
|
+
|
|
121
|
+
async def __aenter__(self) -> Self:
|
|
122
|
+
"""Enter the device's context, open the device."""
|
|
123
|
+
if self.entered:
|
|
124
|
+
raise RuntimeError("LD2410's instance is already entered!")
|
|
125
|
+
|
|
126
|
+
context = await AsyncExitStack().__aenter__()
|
|
127
|
+
try:
|
|
128
|
+
reader, writer = await self._open_serial_connection()
|
|
129
|
+
context.push_async_callback(writer.wait_closed)
|
|
130
|
+
context.callback(writer.close)
|
|
131
|
+
|
|
132
|
+
replies = asyncio.Queue() # type: asyncio.Queue[_ReplyType | None]
|
|
133
|
+
rdtask = asyncio.create_task(
|
|
134
|
+
self._reader_task(reader, replies),
|
|
135
|
+
name='aio_ld2410.ld2410.reader',
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def cancel_reader(task: asyncio.Task[None]) -> None:
|
|
139
|
+
task.cancel('Device is closing')
|
|
140
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
141
|
+
|
|
142
|
+
context.push_async_callback(cancel_reader, rdtask)
|
|
143
|
+
except BaseException:
|
|
144
|
+
await context.aclose()
|
|
145
|
+
raise
|
|
146
|
+
else:
|
|
147
|
+
self._context = context
|
|
148
|
+
self._connected = True
|
|
149
|
+
self._replies = replies
|
|
150
|
+
self._rdtask = rdtask
|
|
151
|
+
self._writer = writer
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
async def __aexit__(
|
|
155
|
+
self,
|
|
156
|
+
exc_type: type[BaseException] | None,
|
|
157
|
+
exc_val: BaseException | None,
|
|
158
|
+
exc_tb: TracebackType | None,
|
|
159
|
+
) -> bool:
|
|
160
|
+
"""Exit the device's context."""
|
|
161
|
+
context = self._context
|
|
162
|
+
try:
|
|
163
|
+
if context is not None:
|
|
164
|
+
await context.__aexit__(exc_type, exc_val, exc_tb)
|
|
165
|
+
finally:
|
|
166
|
+
self._connected = False
|
|
167
|
+
self._context = None
|
|
168
|
+
self._replies = None
|
|
169
|
+
self._rdtask = None
|
|
170
|
+
self._writer = None
|
|
171
|
+
|
|
172
|
+
# Do not prevent the original exception from going further.
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
async def _open_serial_connection(self) -> tuple[StreamReader, StreamWriter]:
|
|
176
|
+
"""Open a serial connection for this device."""
|
|
177
|
+
# This cannot be tested and is supersed during tests.
|
|
178
|
+
return await open_serial_connection(
|
|
179
|
+
baudrate=self._baudrate,
|
|
180
|
+
limit=self._read_bufsize,
|
|
181
|
+
url=self._device,
|
|
182
|
+
) # pragma: no cover
|
|
183
|
+
|
|
184
|
+
async def _reader_task(
|
|
185
|
+
self,
|
|
186
|
+
reader: StreamReader,
|
|
187
|
+
replies: asyncio.Queue[_ReplyType | None],
|
|
188
|
+
) -> None:
|
|
189
|
+
stream = FrameStream()
|
|
190
|
+
try:
|
|
191
|
+
while chunk := await reader.read(2048):
|
|
192
|
+
stream.append(chunk)
|
|
193
|
+
for frame in stream.read_frames():
|
|
194
|
+
try:
|
|
195
|
+
if frame.type == FrameType.COMMAND:
|
|
196
|
+
reply = Reply.parse(frame.data)
|
|
197
|
+
await replies.put(reply)
|
|
198
|
+
elif frame.type == FrameType.REPORT: # pragma: no branch
|
|
199
|
+
report = Report.parse(frame.data)
|
|
200
|
+
async with self._report_condition:
|
|
201
|
+
self._report = container_to_model(ReportStatus, report.data)
|
|
202
|
+
self._report_condition.notify_all()
|
|
203
|
+
except Exception:
|
|
204
|
+
# Happens when we received a frame with unknown content.
|
|
205
|
+
# For the user perpective this will most likely ends with a timeout.
|
|
206
|
+
logger.exception('Unable to handle frame: %s', frame.data.hex(' '))
|
|
207
|
+
finally:
|
|
208
|
+
self._connected = False
|
|
209
|
+
# This is needed here because we may be stuck waiting on a reply.
|
|
210
|
+
with suppress(asyncio.QueueFull):
|
|
211
|
+
replies.put_nowait(None)
|
|
212
|
+
|
|
213
|
+
async def _request(
|
|
214
|
+
self,
|
|
215
|
+
code: CommandCode,
|
|
216
|
+
args: Mapping[str, Any] | None = None,
|
|
217
|
+
) -> _ReplyType:
|
|
218
|
+
"""Send any kind of command to the device.
|
|
219
|
+
|
|
220
|
+
Wait and dequeue the corresponding reply.
|
|
221
|
+
"""
|
|
222
|
+
command = Command.build({'code': code, 'data': args})
|
|
223
|
+
async with self._request_lock:
|
|
224
|
+
if not self.connected:
|
|
225
|
+
raise ConnectionError('We are not connected to the device anymore!')
|
|
226
|
+
|
|
227
|
+
async with timeout(self._command_timeout):
|
|
228
|
+
frame = CommandFrame.build({'data': command})
|
|
229
|
+
# Casts are valid here since we just checked `self.connected`.
|
|
230
|
+
replies = cast(asyncio.Queue[Optional[_ReplyType]], self._replies)
|
|
231
|
+
writer = cast(StreamWriter, self._writer)
|
|
232
|
+
|
|
233
|
+
writer.write(frame)
|
|
234
|
+
await writer.drain()
|
|
235
|
+
|
|
236
|
+
# Loop until we get our reply.
|
|
237
|
+
valid_reply = False
|
|
238
|
+
while not valid_reply:
|
|
239
|
+
reply = await replies.get()
|
|
240
|
+
replies.task_done()
|
|
241
|
+
if reply is None:
|
|
242
|
+
raise ConnectionError('Device has disconnected')
|
|
243
|
+
|
|
244
|
+
valid_reply = bool(code == int(reply.code))
|
|
245
|
+
if not valid_reply:
|
|
246
|
+
logger.warning('Got reply code %u (request was %u)', reply.code, code)
|
|
247
|
+
|
|
248
|
+
# MyPy does not see that reply cannot be None on here.
|
|
249
|
+
reply = cast(_ReplyType, reply)
|
|
250
|
+
if int(reply.status) != ReplyStatus.SUCCESS:
|
|
251
|
+
raise CommandStatusError(f'Command {code} received bad status: {reply.status}')
|
|
252
|
+
|
|
253
|
+
return reply
|
|
254
|
+
|
|
255
|
+
@asynccontextmanager
|
|
256
|
+
async def configure(self) -> AsyncIterator[ConfigModeStatus]:
|
|
257
|
+
"""Enter configuration mode."""
|
|
258
|
+
async with self._config_lock:
|
|
259
|
+
resp = await self._request(CommandCode.CONFIG_ENABLE)
|
|
260
|
+
try:
|
|
261
|
+
yield container_to_model(ConfigModeStatus, resp.data)
|
|
262
|
+
except ModuleRestartedError:
|
|
263
|
+
logger.info('Configuration context has closed due to module restart.')
|
|
264
|
+
finally:
|
|
265
|
+
if not self._restarted and self.connected:
|
|
266
|
+
await self._request(CommandCode.CONFIG_DISABLE)
|
|
267
|
+
self._restarted = False
|
|
268
|
+
|
|
269
|
+
@configuration
|
|
270
|
+
async def get_auxiliary_controls(self) -> AuxiliaryControlStatus:
|
|
271
|
+
"""Get the auxiliary controls (OUT pin)."""
|
|
272
|
+
resp = await self._request(CommandCode.AUXILIARY_CONTROL_GET)
|
|
273
|
+
return container_to_model(AuxiliaryControlStatus, resp.data)
|
|
274
|
+
|
|
275
|
+
@configuration
|
|
276
|
+
async def get_bluetooth_address(self) -> bytes:
|
|
277
|
+
"""Get the module's bluetooth mac address."""
|
|
278
|
+
resp = await self._request(CommandCode.BLUETOOTH_MAC_GET)
|
|
279
|
+
return bytes(resp.data.address)
|
|
280
|
+
|
|
281
|
+
@configuration
|
|
282
|
+
async def get_distance_resolution(self) -> int:
|
|
283
|
+
"""Get the gate distance resolution (in centimeter).
|
|
284
|
+
|
|
285
|
+
This command seems to be available for a few devices / firmwares.
|
|
286
|
+
"""
|
|
287
|
+
resp = await self._request(CommandCode.DISTANCE_RESOLUTION_GET)
|
|
288
|
+
index = int(resp.data.resolution)
|
|
289
|
+
if index == ResolutionIndex.RESOLUTION_20CM:
|
|
290
|
+
return 20
|
|
291
|
+
if index == ResolutionIndex.RESOLUTION_75CM:
|
|
292
|
+
return 75
|
|
293
|
+
raise CommandError(f'Unhandled distance resolution index {index}')
|
|
294
|
+
|
|
295
|
+
@configuration
|
|
296
|
+
async def get_firmware_version(self) -> FirmwareVersion:
|
|
297
|
+
"""Get the current firmware version."""
|
|
298
|
+
resp = await self._request(CommandCode.FIRMWARE_VERSION)
|
|
299
|
+
return container_to_model(FirmwareVersion, resp.data)
|
|
300
|
+
|
|
301
|
+
def get_last_report(self) -> ReportStatus | None:
|
|
302
|
+
"""Get the latest report available, if any."""
|
|
303
|
+
return copy.deepcopy(self._report)
|
|
304
|
+
|
|
305
|
+
@configuration
|
|
306
|
+
async def get_parameters(self) -> ParametersStatus:
|
|
307
|
+
"""Read general parameters."""
|
|
308
|
+
resp = await self._request(CommandCode.PARAMETERS_READ)
|
|
309
|
+
return container_to_model(ParametersStatus, resp.data)
|
|
310
|
+
|
|
311
|
+
async def get_next_report(self) -> ReportStatus:
|
|
312
|
+
"""Wait and get the next available report."""
|
|
313
|
+
async with self._report_condition:
|
|
314
|
+
await self._report_condition.wait()
|
|
315
|
+
report = cast(ReportStatus, self._report)
|
|
316
|
+
return copy.deepcopy(report)
|
|
317
|
+
|
|
318
|
+
async def get_reports(self) -> AsyncIterator[ReportStatus]:
|
|
319
|
+
"""Get reports as an asynchronous iterator."""
|
|
320
|
+
while True:
|
|
321
|
+
yield await self.get_next_report()
|
|
322
|
+
|
|
323
|
+
@configuration
|
|
324
|
+
async def reset_to_factory(self) -> None:
|
|
325
|
+
"""Reset the module to its factory settings.
|
|
326
|
+
|
|
327
|
+
This command is effective after a module restart.
|
|
328
|
+
"""
|
|
329
|
+
await self._request(CommandCode.FACTORY_RESET)
|
|
330
|
+
|
|
331
|
+
@configuration
|
|
332
|
+
async def restart_module(self) -> Never:
|
|
333
|
+
"""Restart the module.
|
|
334
|
+
|
|
335
|
+
Please note that it can take at least 1100ms for it to be available again.
|
|
336
|
+
Raises a `ModuleRestartedError` intended to be caught by the configuration context.
|
|
337
|
+
"""
|
|
338
|
+
await self._request(CommandCode.MODULE_RESTART)
|
|
339
|
+
self._restarted = True
|
|
340
|
+
raise ModuleRestartedError('Module is being restarted')
|
|
341
|
+
|
|
342
|
+
@configuration
|
|
343
|
+
async def set_auxiliary_controls(self, **kwargs: Unpack[AuxiliaryControlConfig]) -> None:
|
|
344
|
+
"""Configure the auxiliary controls (OUT pin)."""
|
|
345
|
+
await self._request(
|
|
346
|
+
CommandCode.AUXILIARY_CONTROL_SET,
|
|
347
|
+
AuxiliaryControlConfig(**kwargs),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
@configuration
|
|
351
|
+
async def set_baudrate(self, baudrate: int) -> None:
|
|
352
|
+
"""Set the serial baud rate to operate.
|
|
353
|
+
|
|
354
|
+
Only baud rates from `BaudRateIndex` are valid, a KeyError is raised otherwise.
|
|
355
|
+
This command is effective after a module restart.
|
|
356
|
+
"""
|
|
357
|
+
await self._request(
|
|
358
|
+
CommandCode.BAUD_RATE_SET,
|
|
359
|
+
{'index': int(BaudRateIndex.from_integer(baudrate))},
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
@configuration
|
|
363
|
+
async def set_bluetooth_mode(self, enabled: bool) -> None:
|
|
364
|
+
"""Set device bluetooth mode."""
|
|
365
|
+
await self._request(CommandCode.BLUETOOTH_SET, {'enabled': enabled})
|
|
366
|
+
|
|
367
|
+
@configuration
|
|
368
|
+
async def set_bluetooth_password(self, password: str) -> None:
|
|
369
|
+
"""Set device bluetooth password.
|
|
370
|
+
|
|
371
|
+
This command seems to be available for a few devices / firmwares.
|
|
372
|
+
The password must have no more than 6 ascii characters.
|
|
373
|
+
"""
|
|
374
|
+
if len(password) > 6 or not password.isascii():
|
|
375
|
+
raise CommandError('Bluetooth password must have less than 7 ascii characters.')
|
|
376
|
+
await self._request(CommandCode.BLUETOOTH_PASSWORD_SET, {'password': password})
|
|
377
|
+
|
|
378
|
+
@configuration
|
|
379
|
+
async def set_distance_resolution(self, resolution: int) -> None:
|
|
380
|
+
"""Set the gate distance resolution (in centimeter).
|
|
381
|
+
|
|
382
|
+
This command seems to be available for a few devices / firmwares.
|
|
383
|
+
This command requires a module restart to be effective.
|
|
384
|
+
`resolution` can only be 20 or 75 centimeters.
|
|
385
|
+
"""
|
|
386
|
+
index = ResolutionIndex.RESOLUTION_75CM
|
|
387
|
+
if resolution == 20:
|
|
388
|
+
index = ResolutionIndex.RESOLUTION_20CM
|
|
389
|
+
elif resolution != 75:
|
|
390
|
+
raise CommandError(f'Unknown index for distance resolution {resolution}')
|
|
391
|
+
await self._request(CommandCode.DISTANCE_RESOLUTION_SET, {'resolution': index})
|
|
392
|
+
|
|
393
|
+
@configuration
|
|
394
|
+
async def set_engineering_mode(self, enabled: bool) -> None:
|
|
395
|
+
"""Set device in engineering mode."""
|
|
396
|
+
code = CommandCode.ENGINEERING_ENABLE if enabled else CommandCode.ENGINEERING_DISABLE
|
|
397
|
+
await self._request(code)
|
|
398
|
+
|
|
399
|
+
@configuration
|
|
400
|
+
async def set_parameters(self, **kwargs: Unpack[ParametersConfig]) -> None:
|
|
401
|
+
"""Set general parameters."""
|
|
402
|
+
# This step is needed to ensure argument correctness.
|
|
403
|
+
await self._request(CommandCode.PARAMETERS_WRITE, ParametersConfig(**kwargs))
|
|
404
|
+
|
|
405
|
+
@configuration
|
|
406
|
+
async def set_gate_sentivity(self, **kwargs: Unpack[GateSensitivityConfig]) -> None:
|
|
407
|
+
"""Set the sensor sensitivity."""
|
|
408
|
+
await self._request(CommandCode.GATE_SENSITIVITY_SET, GateSensitivityConfig(**kwargs))
|