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 +3 -0
- restmesh/data_types.py +160 -0
- restmesh/http_exceptions.py +22 -0
- restmesh/main.py +458 -0
- restmesh-0.1.0.dist-info/METADATA +525 -0
- restmesh-0.1.0.dist-info/RECORD +9 -0
- restmesh-0.1.0.dist-info/WHEEL +4 -0
- restmesh-0.1.0.dist-info/entry_points.txt +2 -0
- restmesh-0.1.0.dist-info/licenses/LICENSES/GPL-3.0-or-later.txt +232 -0
restmesh/__init__.py
ADDED
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()
|