limivia 0.1.0__py3-none-any.whl
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.
- limivia/__init__.py +22 -0
- limivia/_client.py +275 -0
- limivia/_connection.py +327 -0
- limivia/_exceptions.py +25 -0
- limivia/_models.py +15 -0
- limivia/commands/__init__.py +1 -0
- limivia/commands/app.py +30 -0
- limivia/protocol.py +174 -0
- limivia/py.typed +1 -0
- limivia/resources.py +120 -0
- limivia/transports.py +136 -0
- limivia/types/__init__.py +210 -0
- limivia/types/commands.py +218 -0
- limivia/types/events.py +159 -0
- limivia/types/models.py +93 -0
- limivia-0.1.0.dist-info/LICENSE +661 -0
- limivia-0.1.0.dist-info/METADATA +179 -0
- limivia-0.1.0.dist-info/RECORD +20 -0
- limivia-0.1.0.dist-info/WHEEL +4 -0
- limivia-0.1.0.dist-info/entry_points.txt +3 -0
limivia/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from ._client import AsyncLimivia as AsyncLimivia
|
|
2
|
+
from ._client import Backend as Backend
|
|
3
|
+
from ._client import Connection as Connection
|
|
4
|
+
from ._client import Limivia as Limivia
|
|
5
|
+
from ._connection import AsyncConnection as AsyncConnection
|
|
6
|
+
from ._exceptions import (
|
|
7
|
+
DeviceConnectionError as DeviceConnectionError,
|
|
8
|
+
)
|
|
9
|
+
from ._exceptions import (
|
|
10
|
+
InvalidCommandError as InvalidCommandError,
|
|
11
|
+
)
|
|
12
|
+
from ._exceptions import (
|
|
13
|
+
LimiviaError as LimiviaError,
|
|
14
|
+
)
|
|
15
|
+
from ._exceptions import (
|
|
16
|
+
ProtocolError as ProtocolError,
|
|
17
|
+
)
|
|
18
|
+
from ._exceptions import (
|
|
19
|
+
QueueFullError as QueueFullError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
limivia/_client.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from contextlib import AbstractContextManager
|
|
5
|
+
from functools import partial
|
|
6
|
+
from typing import Protocol, Self
|
|
7
|
+
|
|
8
|
+
from anyio.from_thread import BlockingPortal, start_blocking_portal
|
|
9
|
+
|
|
10
|
+
from ._connection import AsyncConnection
|
|
11
|
+
from ._exceptions import DeviceConnectionError
|
|
12
|
+
from .resources import (
|
|
13
|
+
ADCResource,
|
|
14
|
+
GPIOResource,
|
|
15
|
+
OutputsResource,
|
|
16
|
+
PinResource,
|
|
17
|
+
PWMResource,
|
|
18
|
+
StateResource,
|
|
19
|
+
TimerResource,
|
|
20
|
+
)
|
|
21
|
+
from .transports import DeviceBackend, Transport
|
|
22
|
+
from .types import ClientEvent, ClientEventParam, DeviceEvent, DeviceInfo, LinkKind
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Backend(Protocol):
|
|
26
|
+
async def discover(self, kind: LinkKind) -> list[dict[str, str]]: ...
|
|
27
|
+
def transport(self, kind: LinkKind, target: str) -> Transport: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AsyncDevices:
|
|
31
|
+
def __init__(self, client: AsyncLimivia):
|
|
32
|
+
self._client = client
|
|
33
|
+
|
|
34
|
+
async def list(self, *, kind: LinkKind = "ble") -> list[DeviceInfo]:
|
|
35
|
+
client = self._client
|
|
36
|
+
client._check()
|
|
37
|
+
if kind not in ("ble", "usb"):
|
|
38
|
+
raise ValueError("kind 必须为 ble 或 usb。")
|
|
39
|
+
if client._active is not None or client._scanning:
|
|
40
|
+
raise DeviceConnectionError("正在连接设备或搜索设备,请先断开或等待搜索结束。")
|
|
41
|
+
client._scanning = asyncio.current_task()
|
|
42
|
+
try:
|
|
43
|
+
devices = await asyncio.wait_for(client._backend.discover(kind), timeout=12)
|
|
44
|
+
return [DeviceInfo.model_validate(device) for device in devices]
|
|
45
|
+
finally:
|
|
46
|
+
client._scanning = None
|
|
47
|
+
|
|
48
|
+
def connect(self, *, kind: LinkKind = "ble", target: str) -> AsyncConnectionManager:
|
|
49
|
+
return AsyncConnectionManager(self._client, kind, target)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AsyncConnectionManager:
|
|
53
|
+
def __init__(self, client: AsyncLimivia, kind: LinkKind, target: str):
|
|
54
|
+
self._client = client
|
|
55
|
+
self._kind = kind
|
|
56
|
+
self._target = target
|
|
57
|
+
self._connection: AsyncConnection | None = None
|
|
58
|
+
self._entered = False
|
|
59
|
+
|
|
60
|
+
async def __aenter__(self) -> AsyncConnection:
|
|
61
|
+
client = self._client
|
|
62
|
+
client._check()
|
|
63
|
+
if self._entered:
|
|
64
|
+
raise DeviceConnectionError("连接上下文不能重复使用。")
|
|
65
|
+
self._entered = True
|
|
66
|
+
if self._kind not in ("ble", "usb"):
|
|
67
|
+
raise ValueError("kind 必须为 ble 或 usb。")
|
|
68
|
+
if client._active is not None or client._scanning:
|
|
69
|
+
raise DeviceConnectionError("已有活动连接或正在扫描,请先结束当前会话。")
|
|
70
|
+
connection = AsyncConnection(
|
|
71
|
+
client._backend.transport(self._kind, self._target),
|
|
72
|
+
timeout=client.timeout,
|
|
73
|
+
connect_timeout=client.connect_timeout,
|
|
74
|
+
event_queue_size=client.event_queue_size,
|
|
75
|
+
)
|
|
76
|
+
self._connection = connection
|
|
77
|
+
client._active = connection
|
|
78
|
+
client._opening = asyncio.current_task()
|
|
79
|
+
try:
|
|
80
|
+
await connection._open()
|
|
81
|
+
except BaseException:
|
|
82
|
+
client._active = None
|
|
83
|
+
raise
|
|
84
|
+
finally:
|
|
85
|
+
client._opening = None
|
|
86
|
+
return connection
|
|
87
|
+
|
|
88
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
89
|
+
if self._connection is not None:
|
|
90
|
+
try:
|
|
91
|
+
await self._connection.close()
|
|
92
|
+
finally:
|
|
93
|
+
if self._client._active is self._connection:
|
|
94
|
+
self._client._active = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AsyncLimivia:
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
timeout: float = 6,
|
|
102
|
+
connect_timeout: float = 20,
|
|
103
|
+
event_queue_size: int = 256,
|
|
104
|
+
backend: Backend | None = None,
|
|
105
|
+
):
|
|
106
|
+
if timeout <= 0 or connect_timeout <= 0 or event_queue_size < 1:
|
|
107
|
+
raise ValueError("超时和事件缓冲大小必须大于零。")
|
|
108
|
+
self.timeout = timeout
|
|
109
|
+
self.connect_timeout = connect_timeout
|
|
110
|
+
self.event_queue_size = event_queue_size
|
|
111
|
+
self._backend = backend if backend is not None else DeviceBackend()
|
|
112
|
+
self._active: AsyncConnection | None = None
|
|
113
|
+
self._scanning: asyncio.Task | None = None
|
|
114
|
+
self._opening: asyncio.Task | None = None
|
|
115
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
116
|
+
self._closed = False
|
|
117
|
+
self.devices = AsyncDevices(self)
|
|
118
|
+
|
|
119
|
+
def _check(self) -> None:
|
|
120
|
+
if self._closed:
|
|
121
|
+
raise DeviceConnectionError("客户端已关闭。")
|
|
122
|
+
loop = asyncio.get_running_loop()
|
|
123
|
+
if self._loop is not None and self._loop is not loop:
|
|
124
|
+
raise DeviceConnectionError("同一个 AsyncLimivia 必须始终在同一事件循环使用。")
|
|
125
|
+
self._loop = loop
|
|
126
|
+
|
|
127
|
+
async def __aenter__(self) -> Self:
|
|
128
|
+
self._check()
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
132
|
+
await self.close()
|
|
133
|
+
|
|
134
|
+
async def close(self) -> None:
|
|
135
|
+
if self._closed:
|
|
136
|
+
return
|
|
137
|
+
self._check()
|
|
138
|
+
self._closed = True
|
|
139
|
+
if self._scanning and self._scanning is not asyncio.current_task():
|
|
140
|
+
self._scanning.cancel()
|
|
141
|
+
await asyncio.gather(self._scanning, return_exceptions=True)
|
|
142
|
+
if self._opening and self._opening is not asyncio.current_task():
|
|
143
|
+
self._opening.cancel()
|
|
144
|
+
await asyncio.gather(self._opening, return_exceptions=True)
|
|
145
|
+
if self._active:
|
|
146
|
+
await self._active.close()
|
|
147
|
+
self._active = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Connection:
|
|
151
|
+
def __init__(self, connection: AsyncConnection, portal: BlockingPortal):
|
|
152
|
+
self._connection = connection
|
|
153
|
+
self._portal = portal
|
|
154
|
+
self.handshake_request_id = connection.handshake_request_id
|
|
155
|
+
self.state = StateResource(self.send)
|
|
156
|
+
self.gpio = GPIOResource(self.send)
|
|
157
|
+
self.pwm = PWMResource(self.send)
|
|
158
|
+
self.adc = ADCResource(self.send)
|
|
159
|
+
self.timer = TimerResource(self.send)
|
|
160
|
+
self.pin = PinResource(self.send)
|
|
161
|
+
self.outputs = OutputsResource(self.send)
|
|
162
|
+
|
|
163
|
+
def send(self, event: ClientEvent | ClientEventParam) -> int:
|
|
164
|
+
return self._portal.call(self._connection.send, event)
|
|
165
|
+
|
|
166
|
+
def hello(self) -> int:
|
|
167
|
+
return self._portal.call(self._connection.hello)
|
|
168
|
+
|
|
169
|
+
def recv(self) -> DeviceEvent:
|
|
170
|
+
try:
|
|
171
|
+
return self._portal.call(self._connection.recv)
|
|
172
|
+
except StopAsyncIteration:
|
|
173
|
+
raise StopIteration from None
|
|
174
|
+
|
|
175
|
+
def __iter__(self) -> Self:
|
|
176
|
+
return self
|
|
177
|
+
|
|
178
|
+
def __next__(self) -> DeviceEvent:
|
|
179
|
+
return self.recv()
|
|
180
|
+
|
|
181
|
+
def close(self) -> None:
|
|
182
|
+
self._portal.call(self._connection.close)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class ConnectionManager:
|
|
186
|
+
def __init__(self, client: Limivia, kind: LinkKind, target: str):
|
|
187
|
+
self._client = client
|
|
188
|
+
self._kind = kind
|
|
189
|
+
self._target = target
|
|
190
|
+
self._manager: AsyncConnectionManager | None = None
|
|
191
|
+
self._entered = False
|
|
192
|
+
|
|
193
|
+
def __enter__(self) -> Connection:
|
|
194
|
+
if self._entered:
|
|
195
|
+
raise DeviceConnectionError("连接上下文不能重复使用。")
|
|
196
|
+
self._entered = True
|
|
197
|
+
portal, client = self._client._ensure()
|
|
198
|
+
self._manager = client.devices.connect(kind=self._kind, target=self._target)
|
|
199
|
+
return Connection(portal.call(self._manager.__aenter__), portal)
|
|
200
|
+
|
|
201
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
202
|
+
if self._manager is not None and self._client._portal is not None:
|
|
203
|
+
self._client._portal.call(self._manager.__aexit__, exc_type, exc, tb)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class Devices:
|
|
207
|
+
def __init__(self, client: Limivia):
|
|
208
|
+
self._client = client
|
|
209
|
+
|
|
210
|
+
def list(self, *, kind: LinkKind = "ble") -> list[DeviceInfo]:
|
|
211
|
+
portal, client = self._client._ensure()
|
|
212
|
+
return portal.call(partial(client.devices.list, kind=kind))
|
|
213
|
+
|
|
214
|
+
def connect(self, *, kind: LinkKind = "ble", target: str) -> ConnectionManager:
|
|
215
|
+
return ConnectionManager(self._client, kind, target)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class Limivia:
|
|
219
|
+
"""同步门面;后台 asyncio 循环在整个客户端生命周期内持续运行。"""
|
|
220
|
+
|
|
221
|
+
def __init__(
|
|
222
|
+
self,
|
|
223
|
+
*,
|
|
224
|
+
timeout: float = 6,
|
|
225
|
+
connect_timeout: float = 20,
|
|
226
|
+
event_queue_size: int = 256,
|
|
227
|
+
backend: Backend | None = None,
|
|
228
|
+
):
|
|
229
|
+
self._factory = partial(
|
|
230
|
+
AsyncLimivia,
|
|
231
|
+
timeout=timeout,
|
|
232
|
+
connect_timeout=connect_timeout,
|
|
233
|
+
event_queue_size=event_queue_size,
|
|
234
|
+
backend=backend,
|
|
235
|
+
)
|
|
236
|
+
self._portal_context: AbstractContextManager[BlockingPortal] | None = None
|
|
237
|
+
self._portal: BlockingPortal | None = None
|
|
238
|
+
self._client: AsyncLimivia | None = None
|
|
239
|
+
self._closed = False
|
|
240
|
+
self.devices = Devices(self)
|
|
241
|
+
|
|
242
|
+
def _ensure(self) -> tuple[BlockingPortal, AsyncLimivia]:
|
|
243
|
+
if self._closed:
|
|
244
|
+
raise DeviceConnectionError("客户端已关闭。")
|
|
245
|
+
if self._portal is None:
|
|
246
|
+
self._portal_context = start_blocking_portal(backend="asyncio", name="limivia-sync")
|
|
247
|
+
self._portal = self._portal_context.__enter__()
|
|
248
|
+
try:
|
|
249
|
+
self._client = self._portal.call(self._factory)
|
|
250
|
+
except BaseException:
|
|
251
|
+
self._portal_context.__exit__(None, None, None)
|
|
252
|
+
self._portal = None
|
|
253
|
+
raise
|
|
254
|
+
assert self._client is not None
|
|
255
|
+
return self._portal, self._client
|
|
256
|
+
|
|
257
|
+
def __enter__(self) -> Self:
|
|
258
|
+
self._ensure()
|
|
259
|
+
return self
|
|
260
|
+
|
|
261
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
262
|
+
self.close()
|
|
263
|
+
|
|
264
|
+
def close(self) -> None:
|
|
265
|
+
if self._closed:
|
|
266
|
+
return
|
|
267
|
+
self._closed = True
|
|
268
|
+
if self._portal is not None:
|
|
269
|
+
try:
|
|
270
|
+
assert self._client is not None
|
|
271
|
+
self._portal.call(self._client.close)
|
|
272
|
+
finally:
|
|
273
|
+
assert self._portal_context is not None
|
|
274
|
+
self._portal_context.__exit__(None, None, None)
|
|
275
|
+
self._portal = None
|
limivia/_connection.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from collections import deque
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from anyio import CancelScope
|
|
10
|
+
|
|
11
|
+
from ._exceptions import DeviceConnectionError, ProtocolError, QueueFullError
|
|
12
|
+
from .protocol import LineReader, decode_message, encode_command, parse_command
|
|
13
|
+
from .resources import (
|
|
14
|
+
ADCResource,
|
|
15
|
+
GPIOResource,
|
|
16
|
+
OutputsResource,
|
|
17
|
+
PinResource,
|
|
18
|
+
PWMResource,
|
|
19
|
+
StateResource,
|
|
20
|
+
TimerResource,
|
|
21
|
+
)
|
|
22
|
+
from .transports import Transport
|
|
23
|
+
from .types import (
|
|
24
|
+
ClientEvent,
|
|
25
|
+
ClientEventParam,
|
|
26
|
+
CommandFailedEvent,
|
|
27
|
+
ConnectionClosedEvent,
|
|
28
|
+
DeviceError,
|
|
29
|
+
DeviceEvent,
|
|
30
|
+
HelloCommand,
|
|
31
|
+
HelloCompletedEvent,
|
|
32
|
+
ProtocolErrorEvent,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class _Request:
|
|
40
|
+
id: int
|
|
41
|
+
command: ClientEvent
|
|
42
|
+
data: bytes
|
|
43
|
+
done: asyncio.Event
|
|
44
|
+
sent: bool = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AsyncConnection:
|
|
48
|
+
"""一个读循环、一个写循环;事件消费不会负责推进命令或超时。"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self, transport: Transport, *, timeout: float, connect_timeout: float, event_queue_size: int
|
|
52
|
+
):
|
|
53
|
+
self._transport = transport
|
|
54
|
+
self._timeout = timeout
|
|
55
|
+
self._connect_timeout = connect_timeout
|
|
56
|
+
self._event_limit = event_queue_size
|
|
57
|
+
self._outgoing: asyncio.Queue[_Request] = asyncio.Queue(maxsize=8)
|
|
58
|
+
self._pending: dict[int, _Request] = {}
|
|
59
|
+
self._events: deque[DeviceEvent] = deque()
|
|
60
|
+
# 终态另存,缓冲溢出时也能交付已完成命令及至多九个待处理命令的失败。
|
|
61
|
+
self._terminal: deque[DeviceEvent] = deque()
|
|
62
|
+
self._wake = asyncio.Event()
|
|
63
|
+
self._receiving = False
|
|
64
|
+
self._opened = False
|
|
65
|
+
self._closed = False
|
|
66
|
+
self._sequence = 0
|
|
67
|
+
self._tasks: list[asyncio.Task[None]] = []
|
|
68
|
+
self._cleanup_task: asyncio.Task[None] | None = None
|
|
69
|
+
self._ready: asyncio.Future[None] = asyncio.get_running_loop().create_future()
|
|
70
|
+
self.handshake_request_id = 0
|
|
71
|
+
self.state = StateResource(self.send)
|
|
72
|
+
self.gpio = GPIOResource(self.send)
|
|
73
|
+
self.pwm = PWMResource(self.send)
|
|
74
|
+
self.adc = ADCResource(self.send)
|
|
75
|
+
self.timer = TimerResource(self.send)
|
|
76
|
+
self.pin = PinResource(self.send)
|
|
77
|
+
self.outputs = OutputsResource(self.send)
|
|
78
|
+
|
|
79
|
+
async def _open(self) -> None:
|
|
80
|
+
try:
|
|
81
|
+
async with asyncio.timeout(self._connect_timeout):
|
|
82
|
+
await self._transport.open()
|
|
83
|
+
if self._closed:
|
|
84
|
+
raise DeviceConnectionError("连接已关闭。")
|
|
85
|
+
self._opened = True
|
|
86
|
+
self._tasks = [
|
|
87
|
+
asyncio.create_task(self._read(), name="limivia-reader"),
|
|
88
|
+
asyncio.create_task(self._write(), name="limivia-writer"),
|
|
89
|
+
]
|
|
90
|
+
self.handshake_request_id = await self.hello()
|
|
91
|
+
await self._ready
|
|
92
|
+
if self._closed:
|
|
93
|
+
raise DeviceConnectionError("设备在握手结束时断开,请重新连接。")
|
|
94
|
+
except BaseException:
|
|
95
|
+
await self.close()
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
async def hello(self) -> int:
|
|
99
|
+
return await self.send(HelloCommand())
|
|
100
|
+
|
|
101
|
+
async def send(self, event: ClientEvent | ClientEventParam) -> int:
|
|
102
|
+
"""校验并原子入队后返回 ID;执行结果只通过事件流交付。"""
|
|
103
|
+
if not self._opened or self._closed:
|
|
104
|
+
raise DeviceConnectionError("设备连接已关闭,请重新连接。")
|
|
105
|
+
command = parse_command(event)
|
|
106
|
+
if self._sequence >= 2147483647:
|
|
107
|
+
raise DeviceConnectionError("请求 ID 已耗尽,请重新连接。")
|
|
108
|
+
request_id = self._sequence + 1
|
|
109
|
+
data = encode_command(command, request_id)
|
|
110
|
+
request = _Request(request_id, command, data, asyncio.Event())
|
|
111
|
+
try:
|
|
112
|
+
self._outgoing.put_nowait(request)
|
|
113
|
+
except asyncio.QueueFull as exc:
|
|
114
|
+
raise QueueFullError("已有八个等待命令,请消费结果后再发送。") from exc
|
|
115
|
+
# 此方法无挂起点;返回后桥接可在下次 await 前注册外部请求 ID。
|
|
116
|
+
self._sequence = request_id
|
|
117
|
+
self._pending[request_id] = request
|
|
118
|
+
return request_id
|
|
119
|
+
|
|
120
|
+
def __aiter__(self) -> AsyncConnection:
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
async def __anext__(self) -> DeviceEvent:
|
|
124
|
+
return await self.recv()
|
|
125
|
+
|
|
126
|
+
async def recv(self) -> DeviceEvent:
|
|
127
|
+
"""每个连接仅一个消费者;取消等待不会丢失事件。"""
|
|
128
|
+
if self._receiving:
|
|
129
|
+
raise RuntimeError("一个连接只能有一个事件消费者。")
|
|
130
|
+
self._receiving = True
|
|
131
|
+
try:
|
|
132
|
+
while True:
|
|
133
|
+
if self._events:
|
|
134
|
+
return self._events.popleft()
|
|
135
|
+
if self._terminal:
|
|
136
|
+
return self._terminal.popleft()
|
|
137
|
+
if self._closed:
|
|
138
|
+
raise StopAsyncIteration
|
|
139
|
+
self._wake.clear()
|
|
140
|
+
await self._wake.wait()
|
|
141
|
+
finally:
|
|
142
|
+
self._receiving = False
|
|
143
|
+
|
|
144
|
+
def _publish(self, event: DeviceEvent) -> None:
|
|
145
|
+
if self._closed:
|
|
146
|
+
return
|
|
147
|
+
if len(self._events) >= self._event_limit:
|
|
148
|
+
# 当前已经完成的命令不能因溢出丢失。
|
|
149
|
+
if hasattr(event, "request_id"):
|
|
150
|
+
self._terminal.append(event)
|
|
151
|
+
self._terminate(
|
|
152
|
+
DeviceError(
|
|
153
|
+
code="event_buffer_overflow",
|
|
154
|
+
message="事件缓冲区已满,请重连后查询状态。",
|
|
155
|
+
source="sdk",
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
return
|
|
159
|
+
self._events.append(event)
|
|
160
|
+
self._wake.set()
|
|
161
|
+
|
|
162
|
+
def _complete(self, request: _Request, event: DeviceEvent) -> None:
|
|
163
|
+
if self._pending.pop(request.id, None) is None:
|
|
164
|
+
return
|
|
165
|
+
request.done.set()
|
|
166
|
+
if request.id == self.handshake_request_id and not self._ready.done():
|
|
167
|
+
if isinstance(event, HelloCompletedEvent):
|
|
168
|
+
if event.result.protocol == 1 and event.result.board == "luatos-esp32c3-usb":
|
|
169
|
+
self._ready.set_result(None)
|
|
170
|
+
else:
|
|
171
|
+
event = self._failure(
|
|
172
|
+
request,
|
|
173
|
+
"incompatible_device",
|
|
174
|
+
"设备固件或协议不兼容,请安装本工程专用固件。",
|
|
175
|
+
)
|
|
176
|
+
self._ready.set_exception(DeviceConnectionError(event.error.message))
|
|
177
|
+
self._ready.exception()
|
|
178
|
+
else:
|
|
179
|
+
message = (
|
|
180
|
+
event.error.message if isinstance(event, CommandFailedEvent) else "握手失败。"
|
|
181
|
+
)
|
|
182
|
+
self._ready.set_exception(DeviceConnectionError(message))
|
|
183
|
+
self._ready.exception()
|
|
184
|
+
self._publish(event)
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _failure(request: _Request, code: str, message: str) -> CommandFailedEvent:
|
|
188
|
+
return CommandFailedEvent(
|
|
189
|
+
request_id=request.id,
|
|
190
|
+
op=request.command.type,
|
|
191
|
+
error=DeviceError(
|
|
192
|
+
code=code, message=message, source="sdk", may_have_executed=request.sent
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
async def _write(self) -> None:
|
|
197
|
+
try:
|
|
198
|
+
while not self._closed:
|
|
199
|
+
request = await self._outgoing.get()
|
|
200
|
+
deadline = asyncio.get_running_loop().time() + self._timeout
|
|
201
|
+
request.sent = True
|
|
202
|
+
try:
|
|
203
|
+
await asyncio.wait_for(
|
|
204
|
+
self._transport.write(request.data), timeout=min(5, self._timeout)
|
|
205
|
+
)
|
|
206
|
+
except TimeoutError:
|
|
207
|
+
# 半帧写入不能继续复用通道,否则下一条命令会拼接到旧帧。
|
|
208
|
+
self._terminate(
|
|
209
|
+
DeviceError(
|
|
210
|
+
code="write_timeout",
|
|
211
|
+
message="设备写入超时;操作可能已执行,请重连查询状态。",
|
|
212
|
+
source="sdk",
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
return
|
|
216
|
+
try:
|
|
217
|
+
remaining = max(0, deadline - asyncio.get_running_loop().time())
|
|
218
|
+
await asyncio.wait_for(request.done.wait(), timeout=remaining)
|
|
219
|
+
except TimeoutError:
|
|
220
|
+
# 超时不重试。后续 state 请求仍可用于恢复实际状态。
|
|
221
|
+
self._complete(
|
|
222
|
+
request,
|
|
223
|
+
self._failure(
|
|
224
|
+
request, "timeout", "设备响应超时;操作可能已执行,请查询状态。"
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
except asyncio.CancelledError:
|
|
228
|
+
raise
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
logger.warning("设备通信中断: %s", exc, exc_info=True)
|
|
231
|
+
self._terminate(DeviceError(code="connection_lost", message=str(exc), source="sdk"))
|
|
232
|
+
|
|
233
|
+
async def _read(self) -> None:
|
|
234
|
+
reader = LineReader()
|
|
235
|
+
try:
|
|
236
|
+
while not self._closed:
|
|
237
|
+
data = await self._transport.read()
|
|
238
|
+
if not data:
|
|
239
|
+
raise DeviceConnectionError("设备连接已断开,请重新连接。")
|
|
240
|
+
for line in reader.feed(data):
|
|
241
|
+
if self._closed:
|
|
242
|
+
break
|
|
243
|
+
if isinstance(line, ProtocolError):
|
|
244
|
+
self._publish(ProtocolErrorEvent(message=str(line)))
|
|
245
|
+
continue
|
|
246
|
+
try:
|
|
247
|
+
message = json.loads(line)
|
|
248
|
+
except (ValueError, RecursionError):
|
|
249
|
+
# USB 上可能混有固件启动日志;看起来像 JSON 的坏帧显式报告。
|
|
250
|
+
if line.lstrip().startswith(("{", "[")):
|
|
251
|
+
self._publish(ProtocolErrorEvent(message="设备 JSON 帧无效。"))
|
|
252
|
+
else:
|
|
253
|
+
logger.debug("设备日志: %.240s", line)
|
|
254
|
+
continue
|
|
255
|
+
if not isinstance(message, dict):
|
|
256
|
+
self._publish(ProtocolErrorEvent(message="设备帧必须为 JSON 对象。"))
|
|
257
|
+
continue
|
|
258
|
+
request = None
|
|
259
|
+
if "event" not in message and type(message.get("id")) is int:
|
|
260
|
+
request = self._pending.get(message["id"])
|
|
261
|
+
if request is None or not request.sent:
|
|
262
|
+
logger.debug("忽略迟到或未知响应: %s", message.get("id"))
|
|
263
|
+
continue
|
|
264
|
+
try:
|
|
265
|
+
event = decode_message(message, request.command.type if request else None)
|
|
266
|
+
except ProtocolError as exc:
|
|
267
|
+
if request:
|
|
268
|
+
self._complete(
|
|
269
|
+
request, self._failure(request, "invalid_response", str(exc))
|
|
270
|
+
)
|
|
271
|
+
else:
|
|
272
|
+
self._publish(ProtocolErrorEvent(message=str(exc)))
|
|
273
|
+
continue
|
|
274
|
+
if request:
|
|
275
|
+
self._complete(request, event)
|
|
276
|
+
else:
|
|
277
|
+
self._publish(event)
|
|
278
|
+
except asyncio.CancelledError:
|
|
279
|
+
raise
|
|
280
|
+
except Exception as exc:
|
|
281
|
+
logger.warning("设备通信中断: %s", exc, exc_info=True)
|
|
282
|
+
self._terminate(DeviceError(code="connection_lost", message=str(exc), source="sdk"))
|
|
283
|
+
|
|
284
|
+
def _terminate(self, error: DeviceError | None = None) -> None:
|
|
285
|
+
if self._closed:
|
|
286
|
+
return
|
|
287
|
+
self._closed = True
|
|
288
|
+
if not self._ready.done():
|
|
289
|
+
self._ready.set_exception(
|
|
290
|
+
DeviceConnectionError(error.message if error else "连接在握手完成前关闭。")
|
|
291
|
+
)
|
|
292
|
+
self._ready.exception()
|
|
293
|
+
for request in self._pending.values():
|
|
294
|
+
self._terminal.append(
|
|
295
|
+
self._failure(
|
|
296
|
+
request,
|
|
297
|
+
error.code if error else "cancelled",
|
|
298
|
+
error.message if error else "连接已关闭,未完成命令已取消。",
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
request.done.set()
|
|
302
|
+
self._pending.clear()
|
|
303
|
+
self._terminal.append(ConnectionClosedEvent(error=error))
|
|
304
|
+
self._wake.set()
|
|
305
|
+
self._cleanup_task = asyncio.create_task(self._cleanup(), name="limivia-cleanup")
|
|
306
|
+
|
|
307
|
+
async def _cleanup(self) -> None:
|
|
308
|
+
for task in self._tasks:
|
|
309
|
+
task.cancel()
|
|
310
|
+
await asyncio.gather(*self._tasks, return_exceptions=True)
|
|
311
|
+
try:
|
|
312
|
+
await asyncio.wait_for(self._transport.close(), timeout=5)
|
|
313
|
+
except Exception as exc:
|
|
314
|
+
logger.exception("设备资源释放失败")
|
|
315
|
+
for event in self._terminal:
|
|
316
|
+
if isinstance(event, ConnectionClosedEvent) and event.error is None:
|
|
317
|
+
event.error = DeviceError(code="close_failed", message=str(exc), source="sdk")
|
|
318
|
+
|
|
319
|
+
async def close(self) -> None:
|
|
320
|
+
self._terminate()
|
|
321
|
+
assert self._cleanup_task is not None
|
|
322
|
+
with CancelScope(shield=True):
|
|
323
|
+
try:
|
|
324
|
+
await asyncio.shield(self._cleanup_task)
|
|
325
|
+
except asyncio.CancelledError:
|
|
326
|
+
await asyncio.shield(self._cleanup_task)
|
|
327
|
+
raise
|
limivia/_exceptions.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class LimiviaError(Exception):
|
|
2
|
+
"""SDK 错误基类。"""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DeviceConnectionError(LimiviaError, ConnectionError):
|
|
6
|
+
"""连接、握手或传输不可用。"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProtocolError(LimiviaError, ValueError):
|
|
10
|
+
"""协议帧无效。"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InvalidCommandError(LimiviaError, ValueError):
|
|
14
|
+
"""命令尚未发送:参数或操作无效。"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self, message: str, *, code: str = "invalid_argument", request_id: int | None = None
|
|
18
|
+
):
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.code = code
|
|
21
|
+
self.request_id = request_id
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class QueueFullError(LimiviaError):
|
|
25
|
+
"""命令尚未入队;等待队列已满。"""
|
limivia/_models.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Model(BaseModel):
|
|
7
|
+
"""协议数据模型;保留新固件附加的字段。"""
|
|
8
|
+
|
|
9
|
+
model_config = ConfigDict(extra="allow", strict=True)
|
|
10
|
+
|
|
11
|
+
def to_dict(self) -> dict[str, Any]:
|
|
12
|
+
return self.model_dump(mode="json")
|
|
13
|
+
|
|
14
|
+
def to_json(self) -> str:
|
|
15
|
+
return self.model_dump_json()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|