From 49783d9108094b38b059a802ae8b71d90fdf1ef5 Mon Sep 17 00:00:00 2001 From: Taylor Rose Date: Thu, 18 Sep 2025 14:39:46 -0600 Subject: [PATCH 1/7] Add T-Deck device support to Meshtastic CLI - Add T-Deck device definition with verified USB IDs (303a:1001) - Add T-Deck to supported_devices list - Uses t-deck firmware identifier and ESP32 device class - Supports Linux (ttyACM), macOS (cu.usbmodem), and Windows (COM) ports - Update .gitignore to exclude virtual environment files T-Deck is now fully supported and automatically detected by the CLI. --- .gitignore | 3 ++- meshtastic/supported_device.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d6f5bdc..a9683e9 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ examples/__pycache__ meshtastic.spec .hypothesis/ coverage.xml -.ipynb_checkpoints \ No newline at end of file +.ipynb_checkpoints +.cursor/ \ No newline at end of file diff --git a/meshtastic/supported_device.py b/meshtastic/supported_device.py index e5c8271..edcaf38 100755 --- a/meshtastic/supported_device.py +++ b/meshtastic/supported_device.py @@ -217,6 +217,18 @@ seeed_xiao_s3 = SupportedDevice( usb_product_id_in_hex="0059", ) +tdeck = SupportedDevice( + name="T-Deck", + version="", + for_firmware="t-deck", # Confirmed firmware identifier + device_class="esp32", + baseport_on_linux="ttyACM", + baseport_on_mac="cu.usbmodem", + baseport_on_windows="COM", + usb_vendor_id_in_hex="303a", # Espressif Systems (VERIFIED) + usb_product_id_in_hex="1001", # VERIFIED from actual device +) + supported_devices = [ @@ -239,4 +251,5 @@ supported_devices = [ rak11200, nano_g1, seeed_xiao_s3, + tdeck, # T-Deck support added ] From 43a685f012e6ec6dbb2a8b0f7ac133c39aea83cc Mon Sep 17 00:00:00 2001 From: Niklas Roslund Date: Sun, 5 Oct 2025 12:55:59 +0200 Subject: [PATCH 2/7] cli: notmalize ignore IDs for dec, hex and 0x. support YAML [] clear Signed-off-by: Niklas Roslund --- meshtastic/__main__.py | 3 ++- meshtastic/node.py | 54 ++++++++++++++++-------------------------- 2 files changed, 23 insertions(+), 34 deletions(-) diff --git a/meshtastic/__main__.py b/meshtastic/__main__.py index 002f1ec..2475f8f 100644 --- a/meshtastic/__main__.py +++ b/meshtastic/__main__.py @@ -277,7 +277,8 @@ def setPref(config, comp_name, raw_val) -> bool: else: print(f"Adding '{raw_val}' to the {pref.name} list") cur_vals = [x for x in getattr(config_values, pref.name) if x not in [0, "", b""]] - cur_vals.append(val) + if val not in cur_vals: + cur_vals.append(val) getattr(config_values, pref.name)[:] = cur_vals return True diff --git a/meshtastic/node.py b/meshtastic/node.py index b77ad92..1daaee0 100644 --- a/meshtastic/node.py +++ b/meshtastic/node.py @@ -52,6 +52,20 @@ class Node: r += ")" return r + def _to_node_num(self, nodeId: Union[int, str]) -> int: + """Normalize node id from int | '!hex' | '0xhex' | 'decimal' to int.""" + if isinstance(nodeId, int): + return nodeId + s = str(nodeId).strip() + if s.startswith("!"): + s = s[1:] + if s.lower().startswith("0x"): + return int(s, 16) + try: + return int(s, 10) + except ValueError: + return int(s, 16) + def module_available(self, excluded_bit: int) -> bool: """Check DeviceMetadata.excluded_modules to see if a module is available.""" meta = getattr(self.iface, "metadata", None) @@ -714,11 +728,7 @@ class Node: def removeNode(self, nodeId: Union[int, str]): """Tell the node to remove a specific node by ID""" self.ensureSessionKey() - if isinstance(nodeId, str): - if nodeId.startswith("!"): - nodeId = int(nodeId[1:], 16) - else: - nodeId = int(nodeId) + nodeId = self._to_node_num(nodeId) p = admin_pb2.AdminMessage() p.remove_by_nodenum = nodeId @@ -732,11 +742,7 @@ class Node: def setFavorite(self, nodeId: Union[int, str]): """Tell the node to set the specified node ID to be favorited on the NodeDB on the device""" self.ensureSessionKey() - if isinstance(nodeId, str): - if nodeId.startswith("!"): - nodeId = int(nodeId[1:], 16) - else: - nodeId = int(nodeId) + nodeId = self._to_node_num(nodeId) p = admin_pb2.AdminMessage() p.set_favorite_node = nodeId @@ -750,11 +756,7 @@ class Node: def removeFavorite(self, nodeId: Union[int, str]): """Tell the node to set the specified node ID to be un-favorited on the NodeDB on the device""" self.ensureSessionKey() - if isinstance(nodeId, str): - if nodeId.startswith("!"): - nodeId = int(nodeId[1:], 16) - else: - nodeId = int(nodeId) + nodeId = self._to_node_num(nodeId) p = admin_pb2.AdminMessage() p.remove_favorite_node = nodeId @@ -768,11 +770,7 @@ class Node: def setIgnored(self, nodeId: Union[int, str]): """Tell the node to set the specified node ID to be ignored on the NodeDB on the device""" self.ensureSessionKey() - if isinstance(nodeId, str): - if nodeId.startswith("!"): - nodeId = int(nodeId[1:], 16) - else: - nodeId = int(nodeId) + nodeId = self._to_node_num(nodeId) p = admin_pb2.AdminMessage() p.set_ignored_node = nodeId @@ -786,11 +784,7 @@ class Node: def removeIgnored(self, nodeId: Union[int, str]): """Tell the node to set the specified node ID to be un-ignored on the NodeDB on the device""" self.ensureSessionKey() - if isinstance(nodeId, str): - if nodeId.startswith("!"): - nodeId = int(nodeId[1:], 16) - else: - nodeId = int(nodeId) + nodeId = self._to_node_num(nodeId) p = admin_pb2.AdminMessage() p.remove_ignored_node = nodeId @@ -1013,10 +1007,7 @@ class Node: ): # unless a special channel index was used, we want to use the admin index adminIndex = self.iface.localNode._getAdminChannelIndex() logger.debug(f"adminIndex:{adminIndex}") - if isinstance(self.nodeNum, int): - nodeid = self.nodeNum - else: # assume string starting with ! - nodeid = int(self.nodeNum[1:],16) + nodeid = self._to_node_num(self.nodeNum) if "adminSessionPassKey" in self.iface._getOrCreateByNum(nodeid): p.session_passkey = self.iface._getOrCreateByNum(nodeid).get("adminSessionPassKey") return self.iface.sendData( @@ -1037,9 +1028,6 @@ class Node: f"Not ensuring session key, because protocol use is disabled by noProto" ) else: - if isinstance(self.nodeNum, int): - nodeid = self.nodeNum - else: # assume string starting with ! - nodeid = int(self.nodeNum[1:],16) + nodeid = self._to_node_num(self.nodeNum) if self.iface._getOrCreateByNum(nodeid).get("adminSessionPassKey") is None: self.requestConfig(admin_pb2.AdminMessage.SESSIONKEY_CONFIG) From 93da1da3867f0bc9010b93e54e75b352dc51feb9 Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Tue, 14 Oct 2025 07:42:27 -0700 Subject: [PATCH 3/7] flush() is only called if the stream is open This ensures flush() is only called if the stream is open, and logs (but ignores) any exceptions during flush. This should prevent the "Bad file descriptor" error. I see this error a lot on a rak unit, I dont know this is the way but .. you be the judge. --- meshtastic/serial_interface.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/meshtastic/serial_interface.py b/meshtastic/serial_interface.py index a9d5dd7..ef4eeaf 100644 --- a/meshtastic/serial_interface.py +++ b/meshtastic/serial_interface.py @@ -85,10 +85,14 @@ class SerialInterface(StreamInterface): def close(self) -> None: """Close a connection to the device""" - if self.stream: # Stream can be null if we were already closed - self.stream.flush() # FIXME: why are there these two flushes with 100ms sleeps? This shouldn't be necessary - time.sleep(0.1) - self.stream.flush() - time.sleep(0.1) + if hasattr(self, "stream") and self.stream and getattr(self.stream, "is_open", False): + try: + self.stream.flush() + time.sleep(0.1) + # FIXME: why are there these two flushes with 100ms sleeps? This shouldn't be necessary + self.stream.flush() + time.sleep(0.1) + except Exception as e: + logger.debug(f"Exception during flush: {e}") logger.debug("Closing Serial stream") StreamInterface.close(self) From da416fcd20dd59a11721a92f0d64c62ee4b95aa2 Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Tue, 14 Oct 2025 07:54:37 -0700 Subject: [PATCH 4/7] refactor flush The double flush() is not the root cause; the real issue is that code is trying to use the serial port after it has been closed. The error occurs both in close() (during flush()) and later in _writeBytes() (during write()), indicating the port is closed or invalid at those times. --- meshtastic/serial_interface.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/meshtastic/serial_interface.py b/meshtastic/serial_interface.py index ef4eeaf..e3b7400 100644 --- a/meshtastic/serial_interface.py +++ b/meshtastic/serial_interface.py @@ -89,10 +89,12 @@ class SerialInterface(StreamInterface): try: self.stream.flush() time.sleep(0.1) - # FIXME: why are there these two flushes with 100ms sleeps? This shouldn't be necessary - self.stream.flush() - time.sleep(0.1) except Exception as e: logger.debug(f"Exception during flush: {e}") + try: + self.stream.close() + except Exception as e: + logger.debug(f"Exception during close: {e}") + self.stream = None logger.debug("Closing Serial stream") StreamInterface.close(self) From 9285f7d13fc6c978b9192360c4eda8975de6fa32 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Wed, 29 Oct 2025 08:58:25 -0700 Subject: [PATCH 5/7] add waypoint parameter --- examples/waypoint.py | 2 ++ meshtastic/mesh_interface.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/examples/waypoint.py b/examples/waypoint.py index af8dadc..6e3a973 100644 --- a/examples/waypoint.py +++ b/examples/waypoint.py @@ -25,6 +25,7 @@ parser_create = subparsers.add_parser('create', help='Create a new waypoint') parser_create.add_argument('id', help="id of the waypoint") parser_create.add_argument('name', help="name of the waypoint") parser_create.add_argument('description', help="description of the waypoint") +parser_create.add_argument('icon', help="icon of the waypoint") parser_create.add_argument('expire', help="expiration date of the waypoint as interpreted by datetime.fromisoformat") parser_create.add_argument('latitude', help="latitude of the waypoint") parser_create.add_argument('longitude', help="longitude of the waypoint") @@ -44,6 +45,7 @@ with meshtastic.serial_interface.SerialInterface(args.port, debugOut=d) as iface waypoint_id=int(args.id), name=args.name, description=args.description, + icon=args.icon, expire=int(datetime.datetime.fromisoformat(args.expire).timestamp()), latitude=float(args.latitude), longitude=float(args.longitude), diff --git a/meshtastic/mesh_interface.py b/meshtastic/mesh_interface.py index c269209..7052bc5 100644 --- a/meshtastic/mesh_interface.py +++ b/meshtastic/mesh_interface.py @@ -830,6 +830,7 @@ class MeshInterface: # pylint: disable=R0902 self, name, description, + icon, expire: int, waypoint_id: Optional[int] = None, latitude: float = 0.0, @@ -848,6 +849,7 @@ class MeshInterface: # pylint: disable=R0902 w = mesh_pb2.Waypoint() w.name = name w.description = description + w.icon = icon w.expire = expire if waypoint_id is None: # Generate a waypoint's id, NOT a packet ID. From 1d3a7d39f7220a31dbf857e27485092a19e91f01 Mon Sep 17 00:00:00 2001 From: Niklas Roslund Date: Thu, 6 Nov 2025 19:05:34 +0100 Subject: [PATCH 6/7] Move to_node_num to util and updated function calls --- meshtastic/node.py | 29 ++++++++--------------------- meshtastic/util.py | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/meshtastic/node.py b/meshtastic/node.py index 1daaee0..660c705 100644 --- a/meshtastic/node.py +++ b/meshtastic/node.py @@ -16,6 +16,7 @@ from meshtastic.util import ( pskToString, stripnl, message_to_json, + to_node_num, ) logger = logging.getLogger(__name__) @@ -52,20 +53,6 @@ class Node: r += ")" return r - def _to_node_num(self, nodeId: Union[int, str]) -> int: - """Normalize node id from int | '!hex' | '0xhex' | 'decimal' to int.""" - if isinstance(nodeId, int): - return nodeId - s = str(nodeId).strip() - if s.startswith("!"): - s = s[1:] - if s.lower().startswith("0x"): - return int(s, 16) - try: - return int(s, 10) - except ValueError: - return int(s, 16) - def module_available(self, excluded_bit: int) -> bool: """Check DeviceMetadata.excluded_modules to see if a module is available.""" meta = getattr(self.iface, "metadata", None) @@ -728,7 +715,7 @@ class Node: def removeNode(self, nodeId: Union[int, str]): """Tell the node to remove a specific node by ID""" self.ensureSessionKey() - nodeId = self._to_node_num(nodeId) + nodeId = to_node_num(nodeId) p = admin_pb2.AdminMessage() p.remove_by_nodenum = nodeId @@ -742,7 +729,7 @@ class Node: def setFavorite(self, nodeId: Union[int, str]): """Tell the node to set the specified node ID to be favorited on the NodeDB on the device""" self.ensureSessionKey() - nodeId = self._to_node_num(nodeId) + nodeId = to_node_num(nodeId) p = admin_pb2.AdminMessage() p.set_favorite_node = nodeId @@ -756,7 +743,7 @@ class Node: def removeFavorite(self, nodeId: Union[int, str]): """Tell the node to set the specified node ID to be un-favorited on the NodeDB on the device""" self.ensureSessionKey() - nodeId = self._to_node_num(nodeId) + nodeId = to_node_num(nodeId) p = admin_pb2.AdminMessage() p.remove_favorite_node = nodeId @@ -770,7 +757,7 @@ class Node: def setIgnored(self, nodeId: Union[int, str]): """Tell the node to set the specified node ID to be ignored on the NodeDB on the device""" self.ensureSessionKey() - nodeId = self._to_node_num(nodeId) + nodeId = to_node_num(nodeId) p = admin_pb2.AdminMessage() p.set_ignored_node = nodeId @@ -784,7 +771,7 @@ class Node: def removeIgnored(self, nodeId: Union[int, str]): """Tell the node to set the specified node ID to be un-ignored on the NodeDB on the device""" self.ensureSessionKey() - nodeId = self._to_node_num(nodeId) + nodeId = to_node_num(nodeId) p = admin_pb2.AdminMessage() p.remove_ignored_node = nodeId @@ -1007,7 +994,7 @@ class Node: ): # unless a special channel index was used, we want to use the admin index adminIndex = self.iface.localNode._getAdminChannelIndex() logger.debug(f"adminIndex:{adminIndex}") - nodeid = self._to_node_num(self.nodeNum) + nodeid = to_node_num(self.nodeNum) if "adminSessionPassKey" in self.iface._getOrCreateByNum(nodeid): p.session_passkey = self.iface._getOrCreateByNum(nodeid).get("adminSessionPassKey") return self.iface.sendData( @@ -1028,6 +1015,6 @@ class Node: f"Not ensuring session key, because protocol use is disabled by noProto" ) else: - nodeid = self._to_node_num(self.nodeNum) + nodeid = to_node_num(self.nodeNum) if self.iface._getOrCreateByNum(nodeid).get("adminSessionPassKey") is None: self.requestConfig(admin_pb2.AdminMessage.SESSIONKEY_CONFIG) diff --git a/meshtastic/util.py b/meshtastic/util.py index 243dfe9..0e2445a 100644 --- a/meshtastic/util.py +++ b/meshtastic/util.py @@ -692,3 +692,20 @@ def message_to_json(message: Message, multiline: bool=False) -> str: except TypeError: json = MessageToJson(message, including_default_value_fields=True) # type: ignore[call-arg] # pylint: disable=E1123 return stripnl(json) if not multiline else json + + +def to_node_num(node_id: Union[int, str]) -> int: + """ + Normalize a node id from int | '!hex' | '0xhex' | 'decimal' to int. + """ + if isinstance(node_id, int): + return node_id + s = str(node_id).strip() + if s.startswith("!"): + s = s[1:] + if s.lower().startswith("0x"): + return int(s, 16) + try: + return int(s, 10) + except ValueError: + return int(s, 16) From e6c276fe96573e3324c30d4fb2c8861c9c555537 Mon Sep 17 00:00:00 2001 From: Ian McEwen Date: Thu, 6 Nov 2025 14:17:07 -0700 Subject: [PATCH 7/7] Fix trailing whitespace --- meshtastic/supported_device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meshtastic/supported_device.py b/meshtastic/supported_device.py index edcaf38..8035694 100755 --- a/meshtastic/supported_device.py +++ b/meshtastic/supported_device.py @@ -223,7 +223,7 @@ tdeck = SupportedDevice( for_firmware="t-deck", # Confirmed firmware identifier device_class="esp32", baseport_on_linux="ttyACM", - baseport_on_mac="cu.usbmodem", + baseport_on_mac="cu.usbmodem", baseport_on_windows="COM", usb_vendor_id_in_hex="303a", # Espressif Systems (VERIFIED) usb_product_id_in_hex="1001", # VERIFIED from actual device