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