restmesh 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.
restmesh/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026-2026 Franco Masotti (See /README.md)
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
restmesh/data_types.py ADDED
@@ -0,0 +1,160 @@
1
+ # SPDX-FileCopyrightText: 2026-2026 Franco Masotti (See /README.md)
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+ """Bastic data types."""
5
+
6
+ import logging
7
+ from contextvars import ContextVar
8
+ from typing import Annotated, Literal
9
+
10
+ from fastapi import Path
11
+ from pydantic import BeforeValidator, Field, TypeAdapter, ValidationError
12
+
13
+ # Thread safe variable.
14
+ truncated_message: ContextVar[bool] = ContextVar('truncated_message',
15
+ default=False)
16
+
17
+ # See:
18
+ # https://github.com/meshtastic/protobufs/blob/master/meshtastic/mesh.proto
19
+ NodeNum = Annotated[
20
+ int,
21
+ Field(ge=0, le=(2**32) - 1, description='The 32-bit integer node number')]
22
+
23
+ # Should be an 8 char HEX integer.
24
+ NodeId = Annotated[
25
+ str,
26
+ Field(pattern=r'^![0-9a-f]{8}$',
27
+ description=
28
+ "The 8-character lowercase hex string node ID starting with '!'")]
29
+
30
+ node_id_adapter = TypeAdapter(NodeId)
31
+ node_num_adapter = TypeAdapter(NodeNum)
32
+
33
+
34
+ def parse_and_validate_node(v: any) -> str | int:
35
+ r"""Validate and transform the incoming route string using strict NodeId or NodeNum rules."""
36
+ v_str = str(v).strip()
37
+
38
+ # Parse as NodeId if it starts with the '!' character.
39
+ if v_str.startswith('!'):
40
+ try:
41
+ return node_id_adapter.validate_python(v_str)
42
+ except ValidationError:
43
+ raise ValueError(
44
+ f'Invalid NodeId format: \'{v_str}\'. Must be an 8-character lowercase hex string starting with \'!\'.'
45
+ )
46
+
47
+ # Parse as NodeNum if it consists only of digits.
48
+ if v_str.isdigit():
49
+ try:
50
+ return node_num_adapter.validate_python(int(v_str))
51
+ except ValidationError:
52
+ raise ValueError(
53
+ f'Invalid NodeNum value: \'{v_str}\'. Must be a 32-bit integer between 0 and {(2 * 32) - 1}.'
54
+ )
55
+
56
+ # Fallback error if the input format matches neither type pattern.
57
+ raise ValueError(
58
+ f'Invalid node target structure: \'{v_str}\'. Provide a hex NodeId or a numeric NodeNum.'
59
+ )
60
+
61
+
62
+ NodeTarget = Annotated[
63
+ str | int,
64
+ BeforeValidator(parse_and_validate_node),
65
+ Path(
66
+ description=
67
+ 'Target destination: can be a lowercase hex string NodeId (e.g. !2c3b4f5a) or a numeric NodeNum < 2^32 (e.g. 60).'
68
+ ),
69
+ Field(
70
+ description=
71
+ 'Target destination: can be a lowercase hex string NodeId (e.g. !2c3b4f5a) or a numeric NodeNum < 2^32 (e.g. 60).',
72
+ json_schema_extra={
73
+ 'examples': [{
74
+ 'summary': 'Hexadecimal NodeId',
75
+ 'description':
76
+ "The 8-character lowercase hex string node ID starting with '!'",
77
+ 'value': '!2c3b4f5a'
78
+ }, {
79
+ 'summary': 'Decimal NodeNum',
80
+ 'description': 'The 32-bit integer node number',
81
+ 'value': 123456789
82
+ }]
83
+ })]
84
+
85
+ # Channels are from 0 to 7.
86
+ # See:
87
+ # https://python.meshtastic.org/node.html
88
+ ChannelIndex = Annotated[
89
+ int,
90
+ Path(description='The channel index (0 to 7)'),
91
+ Field(ge=0, le=7, description='The channel index (0 to 7)')]
92
+
93
+ # See
94
+ # https://github.com/meshtastic/protobufs/blob/master/meshtastic/portnums.proto
95
+ PortNum = Annotated[
96
+ int,
97
+ Field(ge=0, le=511, description='Protobuf application port number')]
98
+
99
+
100
+ # Before validators.
101
+ def truncate_to_meshtastic_mtu(v: str) -> str:
102
+ """Truncate the string to 200 bytes (237 bytes is the theoretical limit)."""
103
+ # Safe limit.
104
+ MAX_BYTES: int = 200
105
+ SUFFIX: str = '<|TRUNC|>'
106
+ suffix_len = len(SUFFIX.encode('utf-8'))
107
+
108
+ if len(v.encode('utf-8')) <= MAX_BYTES:
109
+ # v is not truncated.
110
+ return v
111
+
112
+ # v is truncated from now on.
113
+ allowed_bytes: int = MAX_BYTES - suffix_len
114
+
115
+ # Avoid truncating at a multi-byte character.
116
+ encoded = v.encode('utf-8')[:allowed_bytes]
117
+ truncated_str: str = encoded.decode('utf-8', errors='ignore')
118
+ logging.info(len(f'{truncated_str}{SUFFIX}'))
119
+
120
+ truncated_message.set(True)
121
+
122
+ return f'{truncated_str}{SUFFIX}'
123
+
124
+
125
+ # See
126
+ # https://buf.build/meshtastic/protobufs/docs/86640f20db7b9b5be42949d18e8d96ad10d47a68%3Ameshtastic#meshtastic.Constants
127
+ TextMessagePayload = Annotated[
128
+ str,
129
+ Field(
130
+ description=
131
+ 'UTF-8 text limited to the 237 byte Meshtastic MTU (200 here for safety)'
132
+ ),
133
+ BeforeValidator(truncate_to_meshtastic_mtu)]
134
+
135
+ # See:
136
+ # https://python.meshtastic.org/mesh_interface.html#meshtastic.mesh_interface.MeshInterface.sendText
137
+ WantAck = Annotated[
138
+ bool,
139
+ Field(
140
+ default=False,
141
+ description=
142
+ '`true` if you want the message sent in a reliable manner (with retries and ack/nak provided for delivery)'
143
+ )]
144
+
145
+ # See:
146
+ # https://python.meshtastic.org/mesh_interface.html#meshtastic.mesh_interface.MeshInterface.sendText
147
+ WantResponse = Annotated[
148
+ bool,
149
+ Field(
150
+ default=True,
151
+ description=
152
+ '`true` if you want the service on the other side to send an application layer response'
153
+ )]
154
+
155
+ AppriseNotificationType = Annotated[
156
+ Literal['info', 'warning', 'success', 'failure'] | None,
157
+ Field(default='info', description='Unused parameter')]
158
+
159
+ AppriseNotificationTitle = Annotated[
160
+ str | None, Field(default='', description='Unused parameter')]
@@ -0,0 +1,22 @@
1
+ # SPDX-FileCopyrightText: 2026-2026 Franco Masotti (See /README.md)
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ from fastapi import HTTPException, status
6
+
7
+ RADIO_UNAVAILABLE_503 = HTTPException(
8
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
9
+ detail='Meshtastic radio interface is not initialized or unavailable.')
10
+
11
+ RADIO_PROTOCOL_ERROR_503 = HTTPException(
12
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
13
+ detail='Radio hardware protocol error.')
14
+
15
+ QUEUE_FULL_429 = HTTPException(
16
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
17
+ detail='Unable to handle more requests, queue full.')
18
+
19
+ RADIO_USB_OR_SERIAL_FAILURE_503 = HTTPException(
20
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
21
+ detail=
22
+ 'Local serial device communication failed. Check USB physical connection.')
restmesh/main.py ADDED
@@ -0,0 +1,458 @@
1
+ # SPDX-FileCopyrightText: 2026-2026 Franco Masotti (See /README.md)
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import argparse
6
+ import asyncio
7
+ import logging
8
+ from contextlib import asynccontextmanager
9
+ from typing import Any, Literal
10
+
11
+ import meshtastic.serial_interface
12
+ import uvicorn
13
+ from fastapi import FastAPI, Request, status
14
+ from google.protobuf.json_format import MessageToDict
15
+ from meshtastic.mesh_interface import MeshInterface
16
+ from pydantic import BaseModel, Field
17
+
18
+ from . import data_types, http_exceptions
19
+
20
+ MESHTASTIC_SERIAL_DEV: str = '/dev/ttyUSB0'
21
+ MESHTASTIC_SERIAL_DEV_RECONNECTION_TIMEOUT_SEC: int = 5
22
+ MESSAGE_QUEUE_TIMEOUT_BETWEEN_SENT_PACKETS_SEC: int = 3
23
+ MESSAGE_QUEUE_SIZE_BEFORE_HTTP_429: int = 10
24
+
25
+ logging.basicConfig(
26
+ level=logging.INFO,
27
+ format='%(asctime)s - %(levelname)s - [%(module)s:%(lineno)d]: %(message)s'
28
+ )
29
+
30
+
31
+ class DetailResponse(BaseModel):
32
+ """FastAPI error response schema."""
33
+
34
+ detail: str
35
+
36
+
37
+ class RadioErrorResponse(BaseModel):
38
+ detail: str = Field(default='Meshtastic radio problem')
39
+
40
+
41
+ class QueueErrorResponse(BaseModel):
42
+ detail: str = Field(default=http_exceptions.QUEUE_FULL_429.detail)
43
+
44
+
45
+ SEND_MESSAGE_EXTRA_ERROR_RESPONSES: dict = {
46
+ 503: {
47
+ 'model':
48
+ RadioErrorResponse,
49
+ 'description':
50
+ 'Meshtastic radio problem. Different errors can be returned.',
51
+ },
52
+ http_exceptions.QUEUE_FULL_429.status_code: {
53
+ 'model':
54
+ QueueErrorResponse,
55
+ 'description':
56
+ 'Unable to handle more requests because the FIFO queue is full',
57
+ }
58
+ }
59
+
60
+
61
+ class MessageQueueTask:
62
+ """A FIFO queue that handles all the outbound Meshtastic messages."""
63
+
64
+ def __init__(self, params: dict[str, Any]):
65
+ self.params = params
66
+ self.future: asyncio.Future = asyncio.get_running_loop().create_future(
67
+ )
68
+
69
+
70
+ async def meshtastic_packet_worker(app: FastAPI):
71
+ r"""Manage outbound messages thread."""
72
+ logging.info(
73
+ 'Meshtastic message sender (packet) background worker started')
74
+
75
+ while True:
76
+ task: MessageQueueTask = await app.state.packet_queue.get()
77
+
78
+ try:
79
+ radio_interface = app.state.radio
80
+ if radio_interface is None:
81
+ raise RuntimeError('Radio is still not ready. Retry later.')
82
+
83
+ p: dict = task.params
84
+
85
+ # See:
86
+ # https://meshtastic.org/docs/overview/mesh-algo/#layer-2-reliable-zero-hop-messaging
87
+ # for wantAck.
88
+ raw_packet: meshtastic.protobuf.mesh_pb2.MeshPacket = radio_interface.sendText(
89
+ text=p['text'],
90
+ destinationId=p['destination_id'],
91
+ wantAck=p['want_ack'],
92
+ wantResponse=p['want_response'],
93
+ channelIndex=p['channel_index'],
94
+ portNum=p['port_num'],
95
+ onResponse=None)
96
+
97
+ # Decode protobuf to Python dict.
98
+ decoded_packet: dict[str, Any] = MessageToDict(
99
+ raw_packet, preserving_proto_field_name=True)
100
+
101
+ logging.info('Full raw packet:')
102
+ logging.info('')
103
+ logging.info(f'{decoded_packet}')
104
+ logging.info('')
105
+
106
+ packet_id: int = getattr(decoded_packet, 'id', 0)
107
+ from_node_raw: int = getattr(decoded_packet, 'from_', 0)
108
+ to_node_raw: int = getattr(decoded_packet, 'to', 0)
109
+ channel_raw: int = getattr(decoded_packet, 'channel', 0)
110
+
111
+ sent_packet: dict[str, Any] = {
112
+ 'id': packet_id,
113
+ 'from': from_node_raw,
114
+ 'to': to_node_raw,
115
+ 'channel': channel_raw,
116
+ 'portnum': p['port_num'],
117
+ 'text': p['text'],
118
+ 'wantAck': p['want_ack'],
119
+ 'wantResponse': p['want_response']
120
+ }
121
+
122
+ logging.info('radio returned:')
123
+ logging.info(f' Packet transmitted with ID -> {packet_id}')
124
+ logging.info(f' text -> \'{p["text"]}\'')
125
+ logging.info(
126
+ f' destinationId -> {p["destination_id"]}')
127
+ logging.info(
128
+ f' channelIndex -> {p["channel_index"]}')
129
+ logging.info(f' portNum -> {p["port_num"]}')
130
+ logging.info('')
131
+
132
+ # No callback data for the moment.
133
+ task.future.set_result((sent_packet, None))
134
+
135
+ # Do not overwhelm the mesh.
136
+ # FIXME: there are probably better ways to do this.
137
+ await asyncio.sleep(MESSAGE_QUEUE_TIMEOUT_BETWEEN_SENT_PACKETS_SEC)
138
+
139
+ except MeshInterface.MeshInterfaceError as e:
140
+ logging.error(f'Meshtastic protocol error: {e}')
141
+ task.future.set_exception(e)
142
+ except Exception as e:
143
+ logging.error(f'Local USB/Serial communication failure: {e}')
144
+ task.future.set_exception(e)
145
+ finally:
146
+ app.state.packet_queue.task_done()
147
+
148
+
149
+ async def meshtastic_reconnector(app: FastAPI):
150
+ while app.state.running:
151
+ if app.state.radio is None:
152
+ logging.info('Connecting to radio')
153
+
154
+ try:
155
+ loop = asyncio.get_running_loop()
156
+
157
+ # Connect to the radio in a background thread so FastAPI can
158
+ # continue working.
159
+ interface = await loop.run_in_executor(
160
+ None, lambda: meshtastic.serial_interface.SerialInterface(
161
+ devPath=MESHTASTIC_SERIAL_DEV))
162
+ app.state.radio = interface
163
+ logging.info(
164
+ f'Connected to radio via serial on {MESHTASTIC_SERIAL_DEV}'
165
+ )
166
+ except FileNotFoundError as e:
167
+ app.state.radio = None
168
+ logging.error(e)
169
+ logging.warning(
170
+ f'Radio serial device {MESHTASTIC_SERIAL_DEV} not found')
171
+ except Exception as e:
172
+ app.state.radio = None
173
+ logging.error(e)
174
+ logging.warning(
175
+ f'Radio serial device {MESHTASTIC_SERIAL_DEV} unavailable')
176
+ else:
177
+ try:
178
+ if hasattr(app.state.radio,
179
+ 'stream') and app.state.radio.stream is None:
180
+ raise Exception('Serial stream interrupted')
181
+ except Exception as e:
182
+ logging.error(e)
183
+ logging.warning(
184
+ 'Detected radio disconnection in background, forcing quit')
185
+ try:
186
+ app.state.radio.close()
187
+ except Exception as e:
188
+ logging.info('Unhandled exception, continuing')
189
+ logging.warning(e)
190
+ app.state.radio = None
191
+
192
+ await asyncio.sleep(MESHTASTIC_SERIAL_DEV_RECONNECTION_TIMEOUT_SEC)
193
+
194
+
195
+ # https://fastapi.tiangolo.com/advanced/events/#lifespan
196
+ @asynccontextmanager
197
+ async def lifespan(app: FastAPI):
198
+ app.state.radio = None
199
+ app.state.running: bool = True
200
+ reconnect_task = asyncio.create_task(meshtastic_reconnector(app))
201
+ app.state.packet_queue = asyncio.Queue(
202
+ maxsize=MESSAGE_QUEUE_SIZE_BEFORE_HTTP_429)
203
+ worker_task = asyncio.create_task(meshtastic_packet_worker(app))
204
+
205
+ yield
206
+
207
+ app.state.running = False
208
+
209
+ worker_task.cancel()
210
+ try:
211
+ await worker_task
212
+ except asyncio.CancelledError:
213
+ logging.info('Meshtastic worker task closed')
214
+
215
+ reconnect_task.cancel()
216
+ try:
217
+ await reconnect_task
218
+ except asyncio.CancelledError:
219
+ logging.info('Meshtastic reconnect task closed')
220
+
221
+ if app.state.radio:
222
+ app.state.radio.close()
223
+ logging.info('Radio connection closed')
224
+
225
+
226
+ app = FastAPI(title='restmesh',
227
+ description='Stateless thread-safe REST API for Meshtastic.',
228
+ version='0.1.0',
229
+ lifespan=lifespan)
230
+
231
+
232
+ class ChannelBroadcastPayload(BaseModel):
233
+ text: data_types.TextMessagePayload
234
+ wantAck: data_types.WantAck = False
235
+ portNum: data_types.PortNum = 1
236
+
237
+
238
+ class NodeDirectPayload(BaseModel):
239
+ text: data_types.TextMessagePayload
240
+ wantAck: data_types.WantAck = False
241
+ wantResponse: data_types.WantResponse = True
242
+ portNum: data_types.PortNum = 1
243
+
244
+
245
+ class AppriseJsonChannelBroadcastPayload(BaseModel):
246
+ version: str
247
+ title: data_types.AppriseNotificationTitle = ''
248
+ message: data_types.TextMessagePayload
249
+ type: data_types.AppriseNotificationType = 'info'
250
+ attachment: list = Field(default=[], description='Unused parameter')
251
+ wantAck: data_types.WantAck = False
252
+ portNum: data_types.PortNum = 1
253
+
254
+
255
+ class AppriseJsonNodeDirectPayload(BaseModel):
256
+ version: str
257
+ title: data_types.AppriseNotificationTitle = ''
258
+ message: data_types.TextMessagePayload
259
+ type: data_types.AppriseNotificationType = 'info'
260
+ attachment: list = Field(default=[], description='Unused parameter')
261
+ wantAck: data_types.WantAck = False
262
+ wantResponse: data_types.WantResponse = True
263
+ portNum: data_types.PortNum = 1
264
+
265
+
266
+ # Response schemas.
267
+ class MeshPacketDetails(BaseModel):
268
+ id: int
269
+ from_node: int | str = Field(alias='from')
270
+ to_node: int | str = Field(alias='to')
271
+ channel: data_types.ChannelIndex
272
+ portnum: data_types.PortNum
273
+ text: str = Field(description='The message sent to the mesh')
274
+ wantAck: data_types.WantAck
275
+ wantResponse: data_types.WantResponse
276
+
277
+
278
+ class MeshActionResponse(BaseModel):
279
+ status: Literal['success']
280
+ routing_mode: Literal['broadcast', 'direct']
281
+ packet: MeshPacketDetails
282
+ onResponse_callback_payload: dict[str, Any] | None = None
283
+ truncated: bool = False
284
+
285
+
286
+ async def meshtastic_send_text(
287
+ # radio_interface: meshtastic.serial_interface.SerialInterface,
288
+ app: FastAPI,
289
+ text: str,
290
+ destination_id: int | str,
291
+ channel_index: int,
292
+ want_ack: bool,
293
+ want_response: bool,
294
+ port_num: int) -> tuple[dict[str, Any], dict[str, Any] | None]:
295
+ """Interface with Python Meshtastic interface.sendText() method."""
296
+ if (not hasattr(app.state, 'radio')
297
+ or not getattr(app.state, 'radio', None)):
298
+ raise http_exceptions.RADIO_UNAVAILABLE_503
299
+ if app.state.packet_queue.qsize() >= MESSAGE_QUEUE_SIZE_BEFORE_HTTP_429:
300
+ raise http_exceptions.QUEUE_FULL_429
301
+
302
+ params: dict = {
303
+ 'text': text,
304
+ 'destination_id': destination_id,
305
+ 'channel_index': channel_index,
306
+ 'want_ack': want_ack,
307
+ 'want_response': want_response,
308
+ 'port_num': port_num
309
+ }
310
+ task = MessageQueueTask(params)
311
+ await app.state.packet_queue.put(task)
312
+
313
+ try:
314
+ sent_packet, callback_data = await task.future
315
+ return sent_packet, callback_data
316
+ except MeshInterface.MeshInterfaceError as e:
317
+ logging.error(f'Meshtastic protocol error: {e}')
318
+ raise http_exceptions.RADIO_PROTOCOL_ERROR_503
319
+ except Exception as e:
320
+ logging.error(f'Local USB/Serial communication or other failure: {e}')
321
+ raise http_exceptions.RADIO_USB_OR_SERIAL_FAILURE_503
322
+
323
+
324
+ @app.post('/api/v1/channels/{channel_index}/messages',
325
+ status_code=status.HTTP_202_ACCEPTED,
326
+ summary='Send a message to a channel',
327
+ response_model=MeshActionResponse,
328
+ responses=SEND_MESSAGE_EXTRA_ERROR_RESPONSES,
329
+ tags=['Core Send'])
330
+ async def send_text_to_channel(
331
+ payload: ChannelBroadcastPayload, request: Request,
332
+ channel_index: data_types.ChannelIndex) -> MeshActionResponse:
333
+ """Broadcast a text message to a specific mesh channel."""
334
+ is_truncated: bool = data_types.truncated_message.get()
335
+ data_types.truncated_message.set(False)
336
+
337
+ packet, _ = await meshtastic_send_text(app=app,
338
+ text=payload.text,
339
+ destination_id=0xffffffff,
340
+ channel_index=channel_index,
341
+ want_ack=payload.wantAck,
342
+ want_response=False,
343
+ port_num=payload.portNum)
344
+ return MeshActionResponse(status='success',
345
+ routing_mode='broadcast',
346
+ packet=MeshPacketDetails(**packet),
347
+ truncated=is_truncated)
348
+
349
+
350
+ @app.post('/api/v1/nodes/{node_target}/messages',
351
+ status_code=status.HTTP_202_ACCEPTED,
352
+ response_model=MeshActionResponse,
353
+ responses=SEND_MESSAGE_EXTRA_ERROR_RESPONSES,
354
+ tags=['Core Send'])
355
+ async def send_text_to_node(
356
+ payload: NodeDirectPayload, request: Request,
357
+ node_target: data_types.NodeTarget) -> MeshActionResponse:
358
+ """Send a DM text to a node via nodeId string or nodeNum integer."""
359
+ is_truncated: bool = data_types.truncated_message.get()
360
+ data_types.truncated_message.set(False)
361
+
362
+ packet, _ = await meshtastic_send_text(app=app,
363
+ text=payload.text,
364
+ destination_id=node_target,
365
+ channel_index=0,
366
+ want_ack=payload.wantAck,
367
+ want_response=payload.wantResponse,
368
+ port_num=payload.portNum)
369
+ return MeshActionResponse(status='success',
370
+ routing_mode='direct',
371
+ packet=MeshPacketDetails(**packet),
372
+ truncated=is_truncated)
373
+
374
+
375
+ @app.post('/api/v1/integrations/apprise/channels/{channel_index}/messages',
376
+ status_code=status.HTTP_202_ACCEPTED,
377
+ response_model=MeshActionResponse,
378
+ responses=SEND_MESSAGE_EXTRA_ERROR_RESPONSES,
379
+ tags=['Integrations'])
380
+ async def apprise_gateway_adapter_send_text_channel(
381
+ payload: AppriseJsonChannelBroadcastPayload,
382
+ channel_index: data_types.ChannelIndex) -> MeshActionResponse:
383
+ """Adapter gateway."""
384
+ is_truncated: bool = data_types.truncated_message.get()
385
+ data_types.truncated_message.set(False)
386
+
387
+ packet, _ = await meshtastic_send_text(app=app,
388
+ text=payload.message,
389
+ destination_id=0xffffffff,
390
+ channel_index=channel_index,
391
+ want_ack=payload.wantAck,
392
+ want_response=False,
393
+ port_num=payload.portNum)
394
+ return MeshActionResponse(status='success',
395
+ routing_mode='broadcast',
396
+ packet=MeshPacketDetails(**packet),
397
+ truncated=is_truncated)
398
+
399
+
400
+ @app.post('/api/v1/integrations/apprise/nodes/{node_target}/messages',
401
+ status_code=status.HTTP_202_ACCEPTED,
402
+ response_model=MeshActionResponse,
403
+ responses=SEND_MESSAGE_EXTRA_ERROR_RESPONSES,
404
+ tags=['Integrations'])
405
+ async def apprise_gateway_adapter_send_text_node(
406
+ payload: AppriseJsonNodeDirectPayload, request: Request,
407
+ node_target: data_types.NodeTarget) -> MeshActionResponse:
408
+ """Adapter gateway."""
409
+ is_truncated: bool = data_types.truncated_message.get()
410
+ data_types.truncated_message.set(False)
411
+
412
+ packet, _ = await meshtastic_send_text(app=app,
413
+ text=payload.message,
414
+ destination_id=node_target,
415
+ channel_index=0,
416
+ want_ack=payload.wantAck,
417
+ want_response=payload.wantResponse,
418
+ port_num=payload.portNum)
419
+ return MeshActionResponse(status='success',
420
+ routing_mode='direct',
421
+ packet=MeshPacketDetails(**packet),
422
+ truncated=is_truncated)
423
+
424
+
425
+ def cli():
426
+ global MESHTASTIC_SERIAL_DEV
427
+
428
+ parser = argparse.ArgumentParser(
429
+ description='restmesh: stateless thread-safe REST API for Meshtastic')
430
+
431
+ parser.add_argument(
432
+ '--host',
433
+ type=str,
434
+ default='127.0.0.1',
435
+ help='Server host listening address (default: 127.0.0.1)')
436
+ parser.add_argument('--port',
437
+ type=int,
438
+ default=8000,
439
+ help='Server listening port (default: 8000)')
440
+ parser.add_argument(
441
+ '--radio-serial-path',
442
+ type=str,
443
+ default=MESHTASTIC_SERIAL_DEV,
444
+ help=
445
+ f'Path of the USB serial device radio (default: {MESHTASTIC_SERIAL_DEV})'
446
+ )
447
+
448
+ args = parser.parse_args()
449
+
450
+ MESHTASTIC_SERIAL_DEV = args.radio_serial_path
451
+ uvicorn.run('restmesh.main:app',
452
+ host=args.host,
453
+ port=args.port,
454
+ reload=False)
455
+
456
+
457
+ if __name__ == '__main__':
458
+ cli()