mtjk 2.7.8.post1__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.
Files changed (241) hide show
  1. meshtastic/.gitignore +1 -0
  2. meshtastic/__init__.py +638 -0
  3. meshtastic/__main__.py +3337 -0
  4. meshtastic/analysis/__init__.py +1 -0
  5. meshtastic/analysis/__main__.py +577 -0
  6. meshtastic/ble_interface.py +75 -0
  7. meshtastic/host_port.py +183 -0
  8. meshtastic/interfaces/ble/__init__.py +67 -0
  9. meshtastic/interfaces/ble/client.py +1260 -0
  10. meshtastic/interfaces/ble/compatibility_service.py +691 -0
  11. meshtastic/interfaces/ble/connection.py +1874 -0
  12. meshtastic/interfaces/ble/constants.py +266 -0
  13. meshtastic/interfaces/ble/coordination.py +468 -0
  14. meshtastic/interfaces/ble/discovery.py +811 -0
  15. meshtastic/interfaces/ble/errors.py +207 -0
  16. meshtastic/interfaces/ble/gating.py +802 -0
  17. meshtastic/interfaces/ble/interface.py +3313 -0
  18. meshtastic/interfaces/ble/lifecycle_compat_service.py +1603 -0
  19. meshtastic/interfaces/ble/lifecycle_controller_runtime.py +358 -0
  20. meshtastic/interfaces/ble/lifecycle_disconnect_runtime.py +558 -0
  21. meshtastic/interfaces/ble/lifecycle_ownership_runtime.py +943 -0
  22. meshtastic/interfaces/ble/lifecycle_primitives.py +686 -0
  23. meshtastic/interfaces/ble/lifecycle_receive_runtime.py +552 -0
  24. meshtastic/interfaces/ble/lifecycle_service.py +21 -0
  25. meshtastic/interfaces/ble/lifecycle_shutdown_runtime.py +608 -0
  26. meshtastic/interfaces/ble/management_compat_service.py +739 -0
  27. meshtastic/interfaces/ble/management_runtime.py +1096 -0
  28. meshtastic/interfaces/ble/management_service.py +22 -0
  29. meshtastic/interfaces/ble/notifications.py +1023 -0
  30. meshtastic/interfaces/ble/policies.py +328 -0
  31. meshtastic/interfaces/ble/receive_compat_service.py +661 -0
  32. meshtastic/interfaces/ble/receive_service.py +1164 -0
  33. meshtastic/interfaces/ble/reconnection.py +641 -0
  34. meshtastic/interfaces/ble/runner.py +727 -0
  35. meshtastic/interfaces/ble/state.py +418 -0
  36. meshtastic/interfaces/ble/utils.py +394 -0
  37. meshtastic/mesh_interface.py +2568 -0
  38. meshtastic/mesh_interface_runtime/__init__.py +26 -0
  39. meshtastic/mesh_interface_runtime/flows.py +581 -0
  40. meshtastic/mesh_interface_runtime/node_data.py +108 -0
  41. meshtastic/mesh_interface_runtime/node_presentation.py +214 -0
  42. meshtastic/mesh_interface_runtime/node_view.py +647 -0
  43. meshtastic/mesh_interface_runtime/receive_pipeline.py +786 -0
  44. meshtastic/mesh_interface_runtime/request_wait.py +544 -0
  45. meshtastic/mesh_interface_runtime/send_pipeline.py +880 -0
  46. meshtastic/mt_config.py +189 -0
  47. meshtastic/node.py +1641 -0
  48. meshtastic/node_runtime/__init__.py +36 -0
  49. meshtastic/node_runtime/channel_export_runtime.py +236 -0
  50. meshtastic/node_runtime/channel_lookup_runtime.py +113 -0
  51. meshtastic/node_runtime/channel_normalization_runtime.py +64 -0
  52. meshtastic/node_runtime/channel_presentation_runtime.py +145 -0
  53. meshtastic/node_runtime/channel_request_runtime.py +251 -0
  54. meshtastic/node_runtime/content_runtime.py +566 -0
  55. meshtastic/node_runtime/response_runtime.py +420 -0
  56. meshtastic/node_runtime/settings_runtime/__init__.py +20 -0
  57. meshtastic/node_runtime/settings_runtime/admin.py +322 -0
  58. meshtastic/node_runtime/settings_runtime/config_runtime.py +111 -0
  59. meshtastic/node_runtime/settings_runtime/message.py +124 -0
  60. meshtastic/node_runtime/settings_runtime/owner.py +104 -0
  61. meshtastic/node_runtime/settings_runtime/response.py +140 -0
  62. meshtastic/node_runtime/seturl/__init__.py +47 -0
  63. meshtastic/node_runtime/seturl/cache.py +180 -0
  64. meshtastic/node_runtime/seturl/context.py +31 -0
  65. meshtastic/node_runtime/seturl/coordinator.py +141 -0
  66. meshtastic/node_runtime/seturl/execution.py +251 -0
  67. meshtastic/node_runtime/seturl/helpers.py +23 -0
  68. meshtastic/node_runtime/seturl/parser.py +84 -0
  69. meshtastic/node_runtime/seturl/planner.py +302 -0
  70. meshtastic/node_runtime/seturl/rollback.py +364 -0
  71. meshtastic/node_runtime/seturl_runtime.py +36 -0
  72. meshtastic/node_runtime/shared.py +48 -0
  73. meshtastic/node_runtime/transport_runtime/__init__.py +25 -0
  74. meshtastic/node_runtime/transport_runtime/ack.py +155 -0
  75. meshtastic/node_runtime/transport_runtime/admin.py +85 -0
  76. meshtastic/node_runtime/transport_runtime/channel.py +307 -0
  77. meshtastic/node_runtime/transport_runtime/position_time.py +140 -0
  78. meshtastic/node_runtime/transport_runtime/session.py +79 -0
  79. meshtastic/ota.py +270 -0
  80. meshtastic/powermon/__init__.py +81 -0
  81. meshtastic/powermon/constants.py +13 -0
  82. meshtastic/powermon/power_supply.py +233 -0
  83. meshtastic/powermon/ppk2.py +342 -0
  84. meshtastic/powermon/riden.py +117 -0
  85. meshtastic/powermon/sim.py +35 -0
  86. meshtastic/powermon/stress.py +231 -0
  87. meshtastic/protobuf/__init__.py +0 -0
  88. meshtastic/protobuf/admin_pb2.py +62 -0
  89. meshtastic/protobuf/admin_pb2.pyi +1122 -0
  90. meshtastic/protobuf/apponly_pb2.py +28 -0
  91. meshtastic/protobuf/apponly_pb2.pyi +52 -0
  92. meshtastic/protobuf/atak_pb2.py +40 -0
  93. meshtastic/protobuf/atak_pb2.pyi +477 -0
  94. meshtastic/protobuf/cannedmessages_pb2.py +26 -0
  95. meshtastic/protobuf/cannedmessages_pb2.pyi +33 -0
  96. meshtastic/protobuf/channel_pb2.py +34 -0
  97. meshtastic/protobuf/channel_pb2.pyi +234 -0
  98. meshtastic/protobuf/clientonly_pb2.py +28 -0
  99. meshtastic/protobuf/clientonly_pb2.pyi +101 -0
  100. meshtastic/protobuf/config_pb2.py +97 -0
  101. meshtastic/protobuf/config_pb2.pyi +1913 -0
  102. meshtastic/protobuf/connection_status_pb2.py +36 -0
  103. meshtastic/protobuf/connection_status_pb2.pyi +228 -0
  104. meshtastic/protobuf/device_ui_pb2.py +42 -0
  105. meshtastic/protobuf/device_ui_pb2.pyi +649 -0
  106. meshtastic/protobuf/deviceonly_pb2.py +52 -0
  107. meshtastic/protobuf/deviceonly_pb2.pyi +459 -0
  108. meshtastic/protobuf/interdevice_pb2.py +30 -0
  109. meshtastic/protobuf/interdevice_pb2.pyi +105 -0
  110. meshtastic/protobuf/localonly_pb2.py +30 -0
  111. meshtastic/protobuf/localonly_pb2.pyi +244 -0
  112. meshtastic/protobuf/mesh_pb2.py +127 -0
  113. meshtastic/protobuf/mesh_pb2.pyi +4021 -0
  114. meshtastic/protobuf/module_config_pb2.py +76 -0
  115. meshtastic/protobuf/module_config_pb2.pyi +1466 -0
  116. meshtastic/protobuf/mqtt_pb2.py +30 -0
  117. meshtastic/protobuf/mqtt_pb2.pyi +155 -0
  118. meshtastic/protobuf/nanopb_pb2.py +35 -0
  119. meshtastic/protobuf/nanopb_pb2.pyi +324 -0
  120. meshtastic/protobuf/paxcount_pb2.py +26 -0
  121. meshtastic/protobuf/paxcount_pb2.pyi +45 -0
  122. meshtastic/protobuf/portnums_pb2.py +26 -0
  123. meshtastic/protobuf/portnums_pb2.pyi +444 -0
  124. meshtastic/protobuf/powermon_pb2.py +32 -0
  125. meshtastic/protobuf/powermon_pb2.pyi +221 -0
  126. meshtastic/protobuf/remote_hardware_pb2.py +28 -0
  127. meshtastic/protobuf/remote_hardware_pb2.pyi +126 -0
  128. meshtastic/protobuf/rtttl_pb2.py +26 -0
  129. meshtastic/protobuf/rtttl_pb2.pyi +33 -0
  130. meshtastic/protobuf/storeforward_pb2.py +34 -0
  131. meshtastic/protobuf/storeforward_pb2.pyi +345 -0
  132. meshtastic/protobuf/telemetry_pb2.py +48 -0
  133. meshtastic/protobuf/telemetry_pb2.pyi +1498 -0
  134. meshtastic/protobuf/xmodem_pb2.py +28 -0
  135. meshtastic/protobuf/xmodem_pb2.pyi +67 -0
  136. meshtastic/py.typed +0 -0
  137. meshtastic/remote_hardware.py +500 -0
  138. meshtastic/serial_interface.py +279 -0
  139. meshtastic/slog/__init__.py +5 -0
  140. meshtastic/slog/arrow.py +445 -0
  141. meshtastic/slog/slog.py +840 -0
  142. meshtastic/stream_interface.py +558 -0
  143. meshtastic/supported_device.py +353 -0
  144. meshtastic/tcp_interface.py +670 -0
  145. meshtastic/test.py +575 -0
  146. meshtastic/tests/__init__.py +0 -0
  147. meshtastic/tests/api_baselines/api_baseline.json +168 -0
  148. meshtastic/tests/api_baselines/api_baseline_master.json +152 -0
  149. meshtastic/tests/cli_test_utils.py +227 -0
  150. meshtastic/tests/conftest.py +609 -0
  151. meshtastic/tests/seturl/__init__.py +1 -0
  152. meshtastic/tests/seturl/conftest.py +228 -0
  153. meshtastic/tests/seturl/test_cache_manager.py +276 -0
  154. meshtastic/tests/seturl/test_coordinator.py +83 -0
  155. meshtastic/tests/seturl/test_execution_engine.py +349 -0
  156. meshtastic/tests/seturl/test_execution_state.py +74 -0
  157. meshtastic/tests/seturl/test_parser.py +151 -0
  158. meshtastic/tests/seturl/test_planners.py +246 -0
  159. meshtastic/tests/seturl/test_rollback_engine.py +448 -0
  160. meshtastic/tests/slog-test-input/power.feather +0 -0
  161. meshtastic/tests/slog-test-input/raw.txt +349 -0
  162. meshtastic/tests/slog-test-input/slog.feather +0 -0
  163. meshtastic/tests/test_analysis.py +729 -0
  164. meshtastic/tests/test_api_baseline_comparison.py +616 -0
  165. meshtastic/tests/test_api_compat.py +141 -0
  166. meshtastic/tests/test_api_contract_snapshot.py +809 -0
  167. meshtastic/tests/test_ble_compat_services_coverage.py +1309 -0
  168. meshtastic/tests/test_ble_connection_runtime.py +82 -0
  169. meshtastic/tests/test_ble_coordination_coverage.py +1235 -0
  170. meshtastic/tests/test_ble_errors_coverage.py +530 -0
  171. meshtastic/tests/test_ble_init.py +204 -0
  172. meshtastic/tests/test_ble_interface_compat_coverage.py +275 -0
  173. meshtastic/tests/test_ble_lifecycle.py +2713 -0
  174. meshtastic/tests/test_ble_reconnection_coverage.py +1824 -0
  175. meshtastic/tests/test_ble_runner_coverage.py +1307 -0
  176. meshtastic/tests/test_content_runtime_additional.py +507 -0
  177. meshtastic/tests/test_examples.py +183 -0
  178. meshtastic/tests/test_fallback_dotmap.py +85 -0
  179. meshtastic/tests/test_import_compatibility.py +412 -0
  180. meshtastic/tests/test_init.py +513 -0
  181. meshtastic/tests/test_int.py +54 -0
  182. meshtastic/tests/test_main.py +4984 -0
  183. meshtastic/tests/test_main_coverage.py +1316 -0
  184. meshtastic/tests/test_main_specific_lines.py +667 -0
  185. meshtastic/tests/test_mesh_interface.py +4576 -0
  186. meshtastic/tests/test_mesh_interface_behavioral_compat.py +1827 -0
  187. meshtastic/tests/test_mesh_interface_traffic_management.py +26 -0
  188. meshtastic/tests/test_meshtasticd_ci.py +144 -0
  189. meshtastic/tests/test_meshtasticd_multinode_ci.py +599 -0
  190. meshtastic/tests/test_meshtasticd_tcp_interface_ci.py +295 -0
  191. meshtastic/tests/test_mt_config.py +301 -0
  192. meshtastic/tests/test_node.py +3234 -0
  193. meshtastic/tests/test_node_behavioral_compat.py +756 -0
  194. meshtastic/tests/test_node_runtime_channel_export.py +576 -0
  195. meshtastic/tests/test_node_runtime_channel_lookup.py +453 -0
  196. meshtastic/tests/test_node_runtime_channel_normalization.py +311 -0
  197. meshtastic/tests/test_node_runtime_channel_presentation.py +234 -0
  198. meshtastic/tests/test_node_runtime_channel_request.py +339 -0
  199. meshtastic/tests/test_node_runtime_content.py +1124 -0
  200. meshtastic/tests/test_node_runtime_response.py +765 -0
  201. meshtastic/tests/test_node_runtime_settings.py +1329 -0
  202. meshtastic/tests/test_node_runtime_transport.py +1287 -0
  203. meshtastic/tests/test_node_view.py +744 -0
  204. meshtastic/tests/test_ota.py +722 -0
  205. meshtastic/tests/test_powermon_power_supply.py +271 -0
  206. meshtastic/tests/test_powermon_ppk2.py +287 -0
  207. meshtastic/tests/test_powermon_ppk2_coverage.py +1447 -0
  208. meshtastic/tests/test_powermon_riden.py +172 -0
  209. meshtastic/tests/test_powermon_stress.py +273 -0
  210. meshtastic/tests/test_receive_pipeline.py +1223 -0
  211. meshtastic/tests/test_remote_hardware.py +304 -0
  212. meshtastic/tests/test_response_handler_compat.py +71 -0
  213. meshtastic/tests/test_runtime_coverage.py +1283 -0
  214. meshtastic/tests/test_send_pipeline.py +1339 -0
  215. meshtastic/tests/test_serial_interface.py +156 -0
  216. meshtastic/tests/test_showNodes_favorite.py +239 -0
  217. meshtastic/tests/test_slog_arrow.py +359 -0
  218. meshtastic/tests/test_slog_coverage.py +1096 -0
  219. meshtastic/tests/test_slog_power_logger.py +481 -0
  220. meshtastic/tests/test_smoke1.py +1230 -0
  221. meshtastic/tests/test_smoke2.py +53 -0
  222. meshtastic/tests/test_smoke_wifi.py +26 -0
  223. meshtastic/tests/test_smokevirt.py +792 -0
  224. meshtastic/tests/test_stream_interface.py +506 -0
  225. meshtastic/tests/test_stream_interface_coverage.py +912 -0
  226. meshtastic/tests/test_supported_device.py +584 -0
  227. meshtastic/tests/test_tcp_interface.py +893 -0
  228. meshtastic/tests/test_tcp_interface_coverage.py +1348 -0
  229. meshtastic/tests/test_tunnel.py +558 -0
  230. meshtastic/tests/test_util.py +1583 -0
  231. meshtastic/tests/test_util_windows_port.py +41 -0
  232. meshtastic/tests/test_version.py +155 -0
  233. meshtastic/tests/test_write_bytes_investigation.py +370 -0
  234. meshtastic/tunnel.py +508 -0
  235. meshtastic/util.py +1467 -0
  236. meshtastic/version.py +39 -0
  237. mtjk-2.7.8.post1.dist-info/METADATA +202 -0
  238. mtjk-2.7.8.post1.dist-info/RECORD +241 -0
  239. mtjk-2.7.8.post1.dist-info/WHEEL +4 -0
  240. mtjk-2.7.8.post1.dist-info/entry_points.txt +5 -0
  241. mtjk-2.7.8.post1.dist-info/licenses/LICENSE.md +232 -0
meshtastic/.gitignore ADDED
@@ -0,0 +1 @@
1
+ __pycache__
meshtastic/__init__.py ADDED
@@ -0,0 +1,638 @@
1
+ """# A library for the Meshtastic Client API.
2
+
3
+ Primary interfaces: SerialInterface, TCPInterface, BLEInterface
4
+
5
+ Install with pip: "[pip3 install mtjk](https://pypi.org/project/mtjk/)"
6
+
7
+ Source code on [github](https://github.com/jeremiah-k/meshtastic-python)
8
+
9
+ notable properties of interface classes:
10
+
11
+ - `nodes` - The database of received nodes. Includes always up-to-date location and username information for each
12
+ node in the mesh. This is a read-only datastructure.
13
+ - `nodesByNum` - like "nodes" but keyed by nodeNum instead of nodeId. As such, includes "unknown" nodes which haven't seen a User packet yet
14
+ - `myInfo` & `metadata` - Contain read-only information about the local radio device (software version, hardware version, etc)
15
+ - `localNode` - Pointer to a node object for the local node
16
+
17
+ notable properties of nodes:
18
+
19
+ - `localConfig` - Current radio settings, can be written to the radio with the `writeConfig` method.
20
+ - `moduleConfig` - Current module settings, can be written to the radio with the `writeConfig` method.
21
+ - `channels` - The node's channels, keyed by index.
22
+
23
+ # Published PubSub topics
24
+
25
+ We use a [publish-subscribe](https://pypubsub.readthedocs.io/en/v4.0.3/) model to communicate asynchronous events. Available
26
+ topics:
27
+
28
+ - `meshtastic.connection.established` - published once we've successfully connected to the radio and downloaded the node DB
29
+ - `meshtastic.connection.lost` - published once we've lost our link to the radio
30
+ - `meshtastic.receive.text(packet)` - delivers a received packet as a dictionary, if you only care about a particular
31
+ type of packet, you should subscribe to the full topic name. If you want to see all packets, simply subscribe to "meshtastic.receive".
32
+ - `meshtastic.receive.position(packet)`
33
+ - `meshtastic.receive.user(packet)`
34
+ - `meshtastic.receive.data.portnum(packet)` (where portnum is an integer or well known PortNum enum)
35
+ - `meshtastic.node.updated(node = NodeInfo)` - published when a node in the DB changes (appears, location changed, username changed, etc...)
36
+ - `meshtastic.log.line(line)` - a raw unparsed log line from the radio
37
+ - `meshtastic.clientNotification(notification, interface) - a ClientNotification sent from the radio
38
+
39
+ We receive position, user, or data packets from the mesh. You probably only care about `meshtastic.receive.data`. The first argument for
40
+ that publish will be the packet. Text or binary data packets (from `sendData` or `sendText`) will both arrive this way. If you print packet
41
+ you'll see the fields in the dictionary. `decoded.data.payload` will contain the raw bytes that were sent. If the packet was sent with
42
+ `sendText`, `decoded.data.text` will **also** be populated with the decoded string. For ASCII these two strings will be the same, but for
43
+ unicode scripts they can be different.
44
+
45
+ # Example Usage
46
+ ```
47
+ import meshtastic
48
+ import meshtastic.serial_interface
49
+ from pubsub import pub
50
+
51
+ def onReceive(packet, interface): # called when a packet arrives
52
+ print(f"Received: {packet}")
53
+
54
+ def onConnection(interface, topic=pub.AUTO_TOPIC): # called when we (re)connect to the radio
55
+ # defaults to broadcast, specify a destination ID if you wish
56
+ interface.sendText("hello mesh")
57
+
58
+ pub.subscribe(onReceive, "meshtastic.receive")
59
+ pub.subscribe(onConnection, "meshtastic.connection.established")
60
+ # By default will try to find a meshtastic device, otherwise provide a device path like /dev/ttyUSB0
61
+ interface = meshtastic.serial_interface.SerialInterface()
62
+
63
+ ```
64
+ """
65
+
66
+ # ruff: noqa: F401
67
+
68
+ import copy as _copy
69
+ import logging
70
+ from importlib import import_module as _import_module
71
+ from typing import Any as _Any
72
+ from typing import Callable as _Callable
73
+ from typing import NamedTuple as _NamedTuple
74
+ from typing import TypeGuard as _TypeGuard
75
+ from typing import cast as _cast
76
+
77
+ from google.protobuf.json_format import MessageToJson
78
+
79
+ from meshtastic.node import Node
80
+ from meshtastic.util import (
81
+ DeferredExecution,
82
+ Timeout,
83
+ catchAndIgnore,
84
+ stripnl,
85
+ )
86
+
87
+ from . import util
88
+ from .protobuf import (
89
+ admin_pb2,
90
+ apponly_pb2,
91
+ channel_pb2,
92
+ config_pb2,
93
+ mesh_pb2,
94
+ mqtt_pb2,
95
+ paxcount_pb2,
96
+ portnums_pb2,
97
+ powermon_pb2,
98
+ remote_hardware_pb2,
99
+ storeforward_pb2,
100
+ telemetry_pb2,
101
+ )
102
+
103
+ pub = _cast(_Any, _import_module("pubsub.pub"))
104
+
105
+ # Keep this module aligned with historical master behavior by intentionally not
106
+ # defining __all__. Public names remain available as module attributes.
107
+
108
+
109
+ def __getattr__(name: str) -> _Any:
110
+ """Provide lazy access to legacy module attributes.
111
+
112
+ When the attribute "serial" is requested, import the third-party pyserial
113
+ module, cache it on the module globals as "serial", and return it. For any
114
+ other attribute, raise AttributeError.
115
+
116
+ Parameters
117
+ ----------
118
+ name : str
119
+ Attribute name being accessed.
120
+
121
+ Returns
122
+ -------
123
+ _Any
124
+ The resolved module object for the requested legacy attribute
125
+ (e.g., the third-party pyserial module for "serial").
126
+
127
+ Raises
128
+ ------
129
+ AttributeError
130
+ If the requested attribute is not provided by this lazy loader.
131
+ """
132
+ # COMPAT_STABLE_SHIM: preserve historical `meshtastic.serial` module access.
133
+ if name == "serial":
134
+ # Keep historical `meshtastic.serial` access to the third-party
135
+ # pyserial module as exposed on master.
136
+ serial_module = _import_module("serial")
137
+ # Cache in module namespace so subsequent accesses bypass __getattr__
138
+ globals()["serial"] = serial_module
139
+ return serial_module
140
+ raise AttributeError( # noqa: TRY003
141
+ f"module {__name__!r} has no attribute {name!r}"
142
+ )
143
+
144
+
145
+ # Note: To follow PEP224, comments should be after the module variable.
146
+
147
+ LOCAL_ADDR = "^local"
148
+ """A special ID that means the local node"""
149
+
150
+ BROADCAST_NUM: int = 0xFFFFFFFF
151
+ """if using 8 bit nodenums this will be shortened on the target"""
152
+
153
+ BROADCAST_ADDR = "^all"
154
+ """A special ID that means broadcast"""
155
+
156
+ OUR_APP_VERSION: int = 20300
157
+ """The numeric buildnumber (shared with android apps) specifying the
158
+ level of device code we are guaranteed to understand
159
+
160
+ format is Mmmss (where M is 1+the numeric major number. i.e. 20120 means 1.1.20
161
+ """
162
+
163
+ NODELESS_WANT_CONFIG_ID = 69420
164
+ """A special thing to pass for want_config_id that instructs nodes to skip sending nodeinfos other than its own."""
165
+
166
+ publishingThread = DeferredExecution("publishing")
167
+ """Process-wide deferred publisher worker.
168
+
169
+ `DeferredExecution.queueWork()` is thread-safe (backed by Queue) and all
170
+ callbacks are serialized on the worker thread.
171
+ """
172
+
173
+ logger = logging.getLogger(__name__)
174
+
175
+ REDACTED_TEXT = "<redacted>"
176
+ REDACTED_BYTES = b"<redacted>"
177
+ DECODE_ERROR_KEY = "error"
178
+
179
+
180
+ ResponseCallback = _Callable[[dict[str, _Any]], _Any]
181
+ ProtobufFactory = _Callable[[], _Any]
182
+ OnReceive = _Callable[[_Any, dict[str, _Any]], None]
183
+
184
+
185
+ class ResponseHandler(_NamedTuple):
186
+ """A pending response callback, waiting for a response to one of our messages."""
187
+
188
+ # requestId: int - used only as a key
189
+ #: a callable to call when a response is received
190
+ callback: ResponseCallback
191
+ #: Whether ACKs and NAKs should be passed to this handler
192
+ ackPermitted: bool = False
193
+ # FIXME, add timestamp and age out old requests
194
+
195
+
196
+ class KnownProtocol(_NamedTuple):
197
+ """Used to automatically decode known protocol payloads."""
198
+
199
+ #: A descriptive name (e.g. "text", "user", "admin")
200
+ name: str
201
+ #: If set, will be called to parse as a protocol buffer
202
+ protobufFactory: ProtobufFactory | None = None
203
+ #: If set, invoked as onReceive(interface, packet)
204
+ onReceive: OnReceive | None = None
205
+
206
+
207
+ def _packet_debug_summary(as_dict: dict[str, _Any]) -> dict[str, _Any]:
208
+ """Return a sanitized packet summary for debug logging.
209
+
210
+ The summary intentionally omits sensitive payload/body fields while retaining
211
+ enough context for troubleshooting receive-handler flows.
212
+ """
213
+ decoded = as_dict.get("decoded")
214
+ decoded_dict = decoded if isinstance(decoded, dict) else {}
215
+ payload = decoded_dict.get("payload")
216
+ payload_len = (
217
+ len(payload)
218
+ if isinstance(payload, (bytes, bytearray, memoryview, str))
219
+ else None
220
+ )
221
+ decoded_keys = sorted(str(key) for key in decoded_dict.keys())
222
+ return {
223
+ "from": as_dict.get("from"),
224
+ "to": as_dict.get("to"),
225
+ "portnum": decoded_dict.get("portnum"),
226
+ "decoded_keys": decoded_keys,
227
+ "payload_len": payload_len,
228
+ }
229
+
230
+
231
+ def _sanitize_last_received(as_dict: dict[str, _Any]) -> dict[str, _Any]:
232
+ """Return a node-cache-safe packet copy for ``node['lastReceived']``.
233
+
234
+ Keeps historical packet structure for compatibility while redacting only
235
+ ``decoded.admin.raw.session_passkey`` when present.
236
+ """
237
+ sanitized = dict(as_dict)
238
+ decoded = sanitized.get("decoded")
239
+ if not isinstance(decoded, dict):
240
+ return sanitized
241
+ if "admin" not in decoded:
242
+ return sanitized
243
+ decoded_sanitized = dict(decoded)
244
+ admin_payload = decoded.get("admin")
245
+ if isinstance(admin_payload, dict):
246
+ admin_sanitized = dict(admin_payload)
247
+ raw_admin = admin_payload.get("raw")
248
+ if isinstance(raw_admin, dict):
249
+ raw_sanitized_dict = dict(raw_admin)
250
+ if "session_passkey" in raw_sanitized_dict:
251
+ raw_sanitized_dict["session_passkey"] = (
252
+ REDACTED_BYTES
253
+ if isinstance(
254
+ raw_sanitized_dict["session_passkey"],
255
+ (bytes, bytearray, memoryview),
256
+ )
257
+ else REDACTED_TEXT
258
+ )
259
+ admin_sanitized["raw"] = raw_sanitized_dict
260
+ elif hasattr(raw_admin, "session_passkey"):
261
+ raw_sanitized_obj: _Any | None
262
+ try:
263
+ raw_sanitized_obj = _copy.deepcopy(raw_admin)
264
+ except Exception: # noqa: BLE001 - preserve packet flow on odd payloads
265
+ logger.debug(
266
+ "deepcopy failed during admin payload redaction; using sentinel payload",
267
+ exc_info=True,
268
+ )
269
+ raw_sanitized_obj = None
270
+ if raw_sanitized_obj is None:
271
+ admin_sanitized["raw"] = {"session_passkey": REDACTED_BYTES}
272
+ else:
273
+ try:
274
+ raw_sanitized_obj.session_passkey = REDACTED_BYTES
275
+ except Exception: # noqa: BLE001 - best effort redaction only
276
+ logger.debug(
277
+ "session_passkey assignment redaction failed; forcing fallback redaction",
278
+ exc_info=True,
279
+ )
280
+ redaction_applied = False
281
+ try:
282
+ delattr(raw_sanitized_obj, "session_passkey")
283
+ redaction_applied = True
284
+ except Exception: # noqa: BLE001 - final fallback below
285
+ redaction_applied = False
286
+ admin_sanitized["raw"] = (
287
+ raw_sanitized_obj
288
+ if redaction_applied
289
+ else {"session_passkey": REDACTED_BYTES}
290
+ )
291
+ else:
292
+ admin_sanitized["raw"] = raw_sanitized_obj
293
+ decoded_sanitized["admin"] = admin_sanitized
294
+ else:
295
+ decoded_sanitized["admin"] = REDACTED_TEXT
296
+ sanitized["decoded"] = decoded_sanitized
297
+ return sanitized
298
+
299
+
300
+ def _is_valid_node_num(value: object) -> _TypeGuard[int]:
301
+ """Return True when value is an integer node number (excluding bool)."""
302
+ return isinstance(value, int) and not isinstance(value, bool)
303
+
304
+
305
+ def _extract_sender_and_decoded(
306
+ as_dict: dict[str, _Any],
307
+ ) -> tuple[int, dict[str, _Any]] | None:
308
+ """Return validated packet sender and decoded payload dictionary.
309
+
310
+ Parameters
311
+ ----------
312
+ as_dict : dict[str, _Any]
313
+ Packet dictionary expected to contain an integer ``from`` and dict
314
+ ``decoded`` payload.
315
+
316
+ Returns
317
+ -------
318
+ tuple[int, dict[str, Any]] | None
319
+ ``(sender, decoded)`` when both fields are present with expected types;
320
+ otherwise ``None``.
321
+ """
322
+ sender = as_dict.get("from")
323
+ decoded = as_dict.get("decoded")
324
+ if not _is_valid_node_num(sender) or not isinstance(decoded, dict):
325
+ return None
326
+ return sender, decoded
327
+
328
+
329
+ def _on_text_receive(iface: _Any, as_dict: dict[str, _Any]) -> None:
330
+ """Decode text payloads from a received packet and update per-node metadata.
331
+
332
+ If the packet's decoded.payload contains valid UTF-8, store the decoded string in
333
+ as_dict["decoded"]["text"]. If decoding fails, leave that field unset and log an error.
334
+ Always invokes the interface's info-update path to refresh node metadata based on the packet.
335
+
336
+ Parameters
337
+ ----------
338
+ iface : _Any
339
+ The interface instance that received the packet.
340
+ as_dict : dict[str, _Any]
341
+ Packet dictionary expected to contain
342
+ decoded.payload (bytes) where the text is stored.
343
+ """
344
+ # We don't throw if the utf8 is invalid in the text message. Instead we just don't populate
345
+ # the decoded.data.text and we log an error message. This at least allows some delivery to
346
+ # the app and the app can deal with the missing decoded representation.
347
+ #
348
+ # Usually btw this problem is caused by apps sending binary data but setting the payload type to
349
+ # text.
350
+ logger.debug("in _on_text_receive() %s", _packet_debug_summary(as_dict))
351
+ try:
352
+ as_bytes = as_dict["decoded"]["payload"]
353
+ as_dict["decoded"]["text"] = as_bytes.decode("utf-8")
354
+ except (UnicodeDecodeError, KeyError, AttributeError, TypeError):
355
+ logger.exception("Malformatted utf8 in text message")
356
+ _receive_info_update(iface, as_dict)
357
+
358
+
359
+ def _on_position_receive(iface: _Any, as_dict: dict[str, _Any]) -> None:
360
+ """Update the sender node's stored position when a received packet contains position data.
361
+
362
+ If as_dict contains a "from" field and a decoded "position", the position is normalized
363
+ using the interface's fixup routine and written to that node's "position" entry.
364
+
365
+ Parameters
366
+ ----------
367
+ iface : _Any
368
+ Interface instance that provides position normalization and node lookup helpers.
369
+ as_dict : dict[str, _Any]
370
+ Packet dictionary expected to contain
371
+ "from" and "decoded"->"position".
372
+ """
373
+ logger.debug("in _on_position_receive() %s", _packet_debug_summary(as_dict))
374
+ packet_guard = _extract_sender_and_decoded(as_dict)
375
+ if packet_guard is None:
376
+ return
377
+ sender, decoded = packet_guard
378
+ if "position" in decoded:
379
+ _receive_info_update(iface, as_dict)
380
+ p = decoded["position"]
381
+ if not isinstance(p, dict):
382
+ logger.debug(
383
+ "Skipping position update from=%s: unexpected payload type %s",
384
+ sender,
385
+ type(p).__name__,
386
+ )
387
+ return
388
+ if DECODE_ERROR_KEY in p:
389
+ logger.debug(
390
+ "Skipping position state update from=%s due to decode error payload",
391
+ sender,
392
+ )
393
+ return
394
+ logger.debug("position payload received from=%s", sender)
395
+ p = iface._fixup_position(p)
396
+ logger.debug("position payload normalized from=%s", sender)
397
+ node = iface._get_or_create_by_num(sender)
398
+ with iface._node_db_lock:
399
+ node["position"] = p
400
+
401
+
402
+ def _on_node_info_receive(iface: _Any, as_dict: dict[str, _Any]) -> None:
403
+ """Update the local node record from a received NodeInfo ("user") payload.
404
+
405
+ When `as_dict` contains a decoded `"user"` entry and a `"from"` sender, stores
406
+ the decoded user protobuf on the sender's node under `node["user"]`, ensures
407
+ `iface.nodes` maps the user's `id` to that node, and refreshes per-node metadata
408
+ via _receive_info_update(iface, as_dict).
409
+
410
+ Parameters
411
+ ----------
412
+ iface : _Any
413
+ Interface instance managing the node database.
414
+ as_dict : dict[str, _Any]
415
+ Received packet dictionary; expected to contain
416
+ `"decoded" -> "user"` and `"from"`.
417
+ """
418
+ logger.debug("in _on_node_info_receive() %s", _packet_debug_summary(as_dict))
419
+ packet_guard = _extract_sender_and_decoded(as_dict)
420
+ if packet_guard is None:
421
+ return
422
+ sender, decoded = packet_guard
423
+ if "user" in decoded:
424
+ _receive_info_update(iface, as_dict)
425
+ p = decoded["user"]
426
+ if not isinstance(p, dict):
427
+ logger.debug(
428
+ "Skipping user update from=%s: unexpected payload type %s",
429
+ sender,
430
+ type(p).__name__,
431
+ )
432
+ return
433
+ if DECODE_ERROR_KEY in p:
434
+ logger.debug(
435
+ "Skipping user state update from=%s due to decode error payload",
436
+ sender,
437
+ )
438
+ return
439
+ # decode user protobufs and update nodedb, provide decoded version as "position" in the published msg
440
+ # update node DB as needed
441
+ n = iface._get_or_create_by_num(sender)
442
+ with iface._node_db_lock:
443
+ n["user"] = p
444
+ # We now have a node ID, make sure it is up-to-date in that table
445
+ node_id = p.get("id") if isinstance(p, dict) else None
446
+ nodes_by_id = iface.nodes
447
+ if isinstance(node_id, str) and node_id and isinstance(nodes_by_id, dict):
448
+ nodes_by_id[node_id] = n
449
+
450
+
451
+ def _on_telemetry_receive(iface: _Any, as_dict: dict[str, _Any]) -> None:
452
+ """Update the appropriate telemetry section on the sender node when a telemetry packet is received.
453
+
454
+ Merges metrics from the packet's `decoded.telemetry` into one of the node's telemetry
455
+ sections: `deviceMetrics`, `environmentMetrics`, `airQualityMetrics`, `powerMetrics`,
456
+ or `localStats`. If the packet lacks a `from` field or none of these sections are
457
+ present, no change is made.
458
+
459
+ Parameters
460
+ ----------
461
+ iface : _Any
462
+ Interface instance used to look up or create the target node.
463
+ as_dict : dict[str, _Any]
464
+ Received packet dictionary; expected to include
465
+ a `from` key and may include `decoded.telemetry`.
466
+ """
467
+ logger.debug("in _on_telemetry_receive() %s", _packet_debug_summary(as_dict))
468
+ packet_guard = _extract_sender_and_decoded(as_dict)
469
+ if packet_guard is None:
470
+ return
471
+ sender, decoded = packet_guard
472
+ _receive_info_update(iface, as_dict)
473
+
474
+ to_update = None
475
+ telemetry = decoded.get("telemetry") or {}
476
+ if not isinstance(telemetry, dict):
477
+ logger.debug(
478
+ "Skipping telemetry update from=%s: unexpected telemetry payload type %s",
479
+ as_dict.get("from"),
480
+ type(telemetry).__name__,
481
+ )
482
+ return
483
+ if "deviceMetrics" in telemetry:
484
+ to_update = "deviceMetrics"
485
+ elif "environmentMetrics" in telemetry:
486
+ to_update = "environmentMetrics"
487
+ elif "airQualityMetrics" in telemetry:
488
+ to_update = "airQualityMetrics"
489
+ elif "powerMetrics" in telemetry:
490
+ to_update = "powerMetrics"
491
+ elif "localStats" in telemetry:
492
+ to_update = "localStats"
493
+ else:
494
+ return
495
+
496
+ update_obj = telemetry.get(to_update)
497
+ if not isinstance(update_obj, dict):
498
+ return
499
+ node = iface._get_or_create_by_num(sender)
500
+ with iface._node_db_lock:
501
+ new_metrics = node.get(to_update, {})
502
+ if not isinstance(new_metrics, dict):
503
+ new_metrics = {}
504
+ new_metrics.update(update_obj)
505
+ logger.debug("updating %s metrics for %s to %s", to_update, sender, new_metrics)
506
+ node[to_update] = new_metrics
507
+
508
+
509
+ def _receive_info_update(iface: _Any, as_dict: dict[str, _Any]) -> None:
510
+ """Update per-node metadata fields based on information present in a received packet dictionary.
511
+
512
+ Parameters
513
+ ----------
514
+ iface : _Any
515
+ The interface instance whose node store will be updated.
516
+ as_dict : dict[str, _Any]
517
+ Parsed packet dictionary; if it contains an integer "from" key, the
518
+ node identified by that value will have these fields set:
519
+ - lastReceived: packet dictionary copy with admin session passkey redacted
520
+ - lastHeard: value of `rxTime` from the packet (or None)
521
+ - snr: value of `rxSnr` from the packet (or None)
522
+ - hopLimit: value of `hopLimit` from the packet (or None)
523
+ """
524
+ sender = as_dict.get("from")
525
+ if not _is_valid_node_num(sender):
526
+ if sender is not None:
527
+ logger.debug(
528
+ "Skipping receive info update due to non-integer sender type: %s",
529
+ type(sender).__name__,
530
+ )
531
+ return
532
+ node = iface._get_or_create_by_num(sender)
533
+ sanitized_last_received = _sanitize_last_received(as_dict)
534
+ with iface._node_db_lock:
535
+ node["lastReceived"] = sanitized_last_received
536
+ node["lastHeard"] = as_dict.get("rxTime")
537
+ node["snr"] = as_dict.get("rxSnr")
538
+ node["hopLimit"] = as_dict.get("hopLimit")
539
+
540
+
541
+ def _on_admin_receive(iface: _Any, as_dict: dict[str, _Any]) -> None:
542
+ """Store the admin session passkey from an admin packet on the sending node.
543
+
544
+ If the expected fields are present in `as_dict`, sets the sender node's
545
+ "adminSessionPassKey" to the extracted `session_passkey`.
546
+
547
+ Parameters
548
+ ----------
549
+ iface : _Any
550
+ The interface instance managing the node database.
551
+ as_dict : dict[str, _Any]
552
+ Received packet dictionary; expected to contain
553
+ `decoded.admin.raw.session_passkey` and a `from` sender field.
554
+ """
555
+ logger.debug("in _on_admin_receive() from=%s", as_dict.get("from"))
556
+ packet_guard = _extract_sender_and_decoded(as_dict)
557
+ if packet_guard is None:
558
+ sender = as_dict.get("from")
559
+ decoded = as_dict.get("decoded")
560
+ if sender is None:
561
+ logger.debug("Dropping admin packet because 'from' field is missing")
562
+ elif not _is_valid_node_num(sender):
563
+ logger.debug("Admin packet has invalid 'from' field type: %r", type(sender))
564
+ elif not isinstance(decoded, dict):
565
+ logger.debug("Admin packet missing decoded dict from=%s", sender)
566
+ return
567
+
568
+ sender, decoded = packet_guard
569
+ _receive_info_update(iface, as_dict)
570
+
571
+ admin_payload = decoded.get("admin")
572
+ if not isinstance(admin_payload, dict):
573
+ logger.debug("Admin packet missing admin payload dict from=%s", sender)
574
+ return
575
+
576
+ raw_admin = admin_payload.get("raw")
577
+ session_passkey = getattr(raw_admin, "session_passkey", None)
578
+ if session_passkey is None and isinstance(raw_admin, dict):
579
+ session_passkey = raw_admin.get("session_passkey")
580
+
581
+ if session_passkey is None:
582
+ logger.debug(
583
+ "Admin session passkey not extracted from admin packet from=%s",
584
+ sender,
585
+ )
586
+ return
587
+
588
+ node = iface._get_or_create_by_num(sender)
589
+ with iface._node_db_lock:
590
+ node["adminSessionPassKey"] = session_passkey
591
+
592
+
593
+ # Well known message payloads can register decoders for automatic protobuf parsing.
594
+ protocols = {
595
+ portnums_pb2.PortNum.TEXT_MESSAGE_APP: KnownProtocol(
596
+ "text", onReceive=_on_text_receive
597
+ ),
598
+ portnums_pb2.PortNum.RANGE_TEST_APP: KnownProtocol(
599
+ "rangetest", onReceive=_on_text_receive
600
+ ),
601
+ portnums_pb2.PortNum.DETECTION_SENSOR_APP: KnownProtocol(
602
+ "detectionsensor", onReceive=_on_text_receive
603
+ ),
604
+ portnums_pb2.PortNum.POSITION_APP: KnownProtocol(
605
+ "position", mesh_pb2.Position, _on_position_receive
606
+ ),
607
+ portnums_pb2.PortNum.NODEINFO_APP: KnownProtocol(
608
+ "user", mesh_pb2.User, _on_node_info_receive
609
+ ),
610
+ portnums_pb2.PortNum.ADMIN_APP: KnownProtocol(
611
+ "admin", admin_pb2.AdminMessage, _on_admin_receive
612
+ ),
613
+ portnums_pb2.PortNum.ROUTING_APP: KnownProtocol("routing", mesh_pb2.Routing),
614
+ portnums_pb2.PortNum.TELEMETRY_APP: KnownProtocol(
615
+ "telemetry", telemetry_pb2.Telemetry, _on_telemetry_receive
616
+ ),
617
+ portnums_pb2.PortNum.REMOTE_HARDWARE_APP: KnownProtocol(
618
+ "remotehw", remote_hardware_pb2.HardwareMessage
619
+ ),
620
+ portnums_pb2.PortNum.SIMULATOR_APP: KnownProtocol("simulator", mesh_pb2.Compressed),
621
+ portnums_pb2.PortNum.TRACEROUTE_APP: KnownProtocol(
622
+ "traceroute", mesh_pb2.RouteDiscovery
623
+ ),
624
+ portnums_pb2.PortNum.POWERSTRESS_APP: KnownProtocol(
625
+ "powerstress", powermon_pb2.PowerStressMessage
626
+ ),
627
+ portnums_pb2.PortNum.WAYPOINT_APP: KnownProtocol("waypoint", mesh_pb2.Waypoint),
628
+ portnums_pb2.PortNum.PAXCOUNTER_APP: KnownProtocol(
629
+ "paxcounter", paxcount_pb2.Paxcount
630
+ ),
631
+ portnums_pb2.PortNum.STORE_FORWARD_APP: KnownProtocol(
632
+ "storeforward", storeforward_pb2.StoreAndForward
633
+ ),
634
+ portnums_pb2.PortNum.NEIGHBORINFO_APP: KnownProtocol(
635
+ "neighborinfo", mesh_pb2.NeighborInfo
636
+ ),
637
+ portnums_pb2.PortNum.MAP_REPORT_APP: KnownProtocol("mapreport", mqtt_pb2.MapReport),
638
+ }