Compare commits

...

51 Commits

Author SHA1 Message Date
github-actions
e2c9c1315e bump version to 2.5.12 2025-02-19 17:21:25 +00:00
Ian McEwen
f41ef042a9 Merge pull request #708 from mikeymakesit/add-channel
Add new channels from an add URL with the new --ch-add-url option
2025-02-19 10:18:09 -07:00
Ian McEwen
84bec5a7c4 fix up misc. lint/type/test issues, streamline, align argument names and groupings 2025-02-19 10:15:27 -07:00
Ian McEwen
985366c812 Merge pull request #710 from digitaldisarray/master
Add text message port cli option
2025-02-19 09:55:01 -07:00
Ian McEwen
3954fbd404 fix misc lint, test, type complaints from CI 2025-02-19 09:52:16 -07:00
Ian McEwen
5f174b2850 Merge pull request #731 from migillett/464/0x-prefix
464: allow for 0x node prefix values
2025-02-19 09:30:15 -07:00
Michael Gillett
23ea19c00b linter fixes following failed build 2025-02-19 11:26:49 -05:00
Ian McEwen
acc47146c9 Merge pull request #736 from dandrzejewski/master
Add optional CLI parameter to specify node info fields to show with --nodes parameter.
2025-02-19 09:26:17 -07:00
Ian McEwen
dd8803793d fix test mock 2025-02-19 09:21:51 -07:00
Ian McEwen
68ec588804 remove trailing whitespace 2025-02-19 09:15:45 -07:00
David Andrzejewski
2f48594dc3 Fix a pylint error regarding typing. 2025-02-18 22:09:37 -05:00
David Andrzejewski
c7c3c69fc3 Fix a pylint error regarding typing. 2025-02-18 22:08:26 -05:00
David Andrzejewski
53e40d5aab Fix a pylint error 2025-02-18 22:05:46 -05:00
David Andrzejewski
dd3da6a670 Merge remote-tracking branch 'origin/master' 2025-02-18 21:18:59 -05:00
David Andrzejewski
e500b399f4 Merge branch 'meshtastic:master' into master 2025-02-18 21:18:48 -05:00
David Andrzejewski
27ac28e300 Fixing indentation per project coding standards. 2025-02-18 21:18:22 -05:00
David Andrzejewski
6bc5f5e305 Fix count vs len on a list (https://github.com/meshtastic/python/pull/736#discussion_r1960099918) 2025-02-18 21:15:31 -05:00
Michael Gillett
c844e4e0fe help details for new prefix values 2025-02-18 15:25:24 -05:00
Michael Gillett
060df86bb6 Update __main__.py 2025-02-18 15:10:55 -05:00
Michael Gillett
7d87d5037e Update __main__.py 2025-02-18 15:10:26 -05:00
Michael Gillett
4ec7698d94 metavars being a PAIN 2025-02-18 15:09:59 -05:00
Michael Gillett
7cc65aa08a missed one 2025-02-18 15:06:20 -05:00
Michael Gillett
cc411ce0bb remove nargs and unneeded fstrings 2025-02-18 15:05:31 -05:00
Michael Gillett
edff956f9d specify nargs 2025-02-18 14:53:01 -05:00
Michael Gillett
bd68739158 >= 8 instead of > 8 2025-02-18 14:30:28 -05:00
Michael Gillett
530d92ead2 remove redundant f string 2025-02-18 14:24:04 -05:00
Michael Gillett
60f9dc6266 new implementation 2025-02-18 14:23:55 -05:00
Michael Gillett
f9ae021e43 remove old implementation 2025-02-18 14:23:40 -05:00
Michael Gillett
317d81c983 list -> tuple fix for pytest 2025-02-18 14:02:48 -05:00
Ian McEwen
5837bd0172 Merge pull request #734 from dudash/master
catch unhandled OSError when serial port in use
2025-02-18 09:52:03 -07:00
Ian McEwen
5487f7a791 Merge pull request #735 from tache/master
Added descriptive FileNotFoundError handler for serial device connection
2025-02-18 09:51:17 -07:00
Ian McEwen
c6d8a540eb Bump to 2.5.12a0 2025-02-18 09:43:55 -07:00
Ian McEwen
0962c9b058 protobufs: v2.5.22 2025-02-18 09:43:32 -07:00
Ian McEwen
4f98602ac2 ignore argcomplete import in main 2025-02-18 09:41:54 -07:00
David Andrzejewski
6ebddb67c0 Refactored showNodes. It now uses a lookup table to determine the human-readable names of the fields. 2025-02-15 17:18:14 -05:00
David Andrzejewski
82554a1f18 Add optional parameter to specify what fields to show with --nodes 2025-02-15 02:23:13 -05:00
tache
8c115dc636 Added descriptive FileNotFoundError handler for serial device connection
- Added a detailed error message for FileNotFoundError when the serial device is not found.
- Included troubleshooting steps for common issues.
2025-02-09 14:23:31 -05:00
dudash
e2fe359527 catch unhandled OSError when serial port in use 2025-02-08 14:51:53 -05:00
Michael Gillett
5600ce92b0 update to comments 2025-01-26 13:10:56 -05:00
Michael Gillett
efb848adf9 comments explaining logic 2025-01-26 12:58:18 -05:00
Michael Gillett
0d8646189f remove logging.info used for local debugging 2025-01-26 12:56:17 -05:00
Michael Gillett
d0023df8ca slight logic rework 2025-01-26 12:55:03 -05:00
Michael Gillett
b522abf33e update docs 2025-01-26 12:06:31 -05:00
Michael Gillett
c086b6372e Update mesh_interface.py 2025-01-26 11:58:48 -05:00
github-actions
6ec506fe3b bump version to 2.5.11 2025-01-24 17:22:13 +00:00
Ian McEwen
fc3b81dfde protobufs: v2.5.20 2025-01-24 10:19:13 -07:00
Ben Meadors
9c53ea017c Merge pull request #730 from migillett/feature/nodes-add-role
#692 feature/nodes-add-role
2025-01-23 19:07:46 -06:00
Michael Gillett
1e6625d062 feature/nodes-add-role 2025-01-21 22:31:33 -05:00
digitaldisarray
0fb72b8ad1 Only allow PRIVATE_APP custom port 2024-12-31 15:15:18 -08:00
digitaldisarray
74c911cb75 Add text message port cli option 2024-12-06 17:54:08 -08:00
Mike Hornung
579383cd5a Add new channels from an add URL with the new --ch-add-url option 2024-12-01 23:33:47 -08:00
15 changed files with 356 additions and 162 deletions

17
.vscode/launch.json vendored
View File

@@ -245,6 +245,23 @@
"module": "meshtastic", "module": "meshtastic",
"justMyCode": true, "justMyCode": true,
"args": ["--debug", "--nodes"] "args": ["--debug", "--nodes"]
},
{
"name": "meshtastic nodes table",
"type": "debugpy",
"request": "launch",
"module": "meshtastic",
"justMyCode": true,
"args": ["--nodes"]
},
{
"name": "meshtastic nodes table with show-fields",
"type": "debugpy",
"request": "launch",
"module": "meshtastic",
"justMyCode": true,
"args": ["--nodes", "--show-fields", "AKA,Pubkey,Role,Role,Role,Latitude,Latitude,deviceMetrics.voltage"]
} }
] ]
} }

View File

@@ -11,7 +11,7 @@ from types import ModuleType
import argparse import argparse
argcomplete: Union[None, ModuleType] = None argcomplete: Union[None, ModuleType] = None
try: try:
import argcomplete import argcomplete # type: ignore
except ImportError as e: except ImportError as e:
pass # already set to None by default above pass # already set to None by default above
@@ -226,7 +226,7 @@ def setPref(config, comp_name, raw_val) -> bool:
logging.debug(f"valStr:{raw_val} val:{val}") logging.debug(f"valStr:{raw_val} val:{val}")
if snake_name == "wifi_psk" and len(str(raw_val)) < 8: if snake_name == "wifi_psk" and len(str(raw_val)) < 8:
print(f"Warning: network.wifi_psk must be 8 or more characters.") print("Warning: network.wifi_psk must be 8 or more characters.")
return False return False
enumType = pref.enum_type enumType = pref.enum_type
@@ -483,6 +483,7 @@ def onConnected(interface):
if checkChannel(interface, channelIndex): if checkChannel(interface, channelIndex):
print( print(
f"Sending text message {args.sendtext} to {args.dest} on channelIndex:{channelIndex}" f"Sending text message {args.sendtext} to {args.dest} on channelIndex:{channelIndex}"
f" {'using PRIVATE_APP port' if args.private else ''}"
) )
interface.sendText( interface.sendText(
args.sendtext, args.sendtext,
@@ -490,6 +491,7 @@ def onConnected(interface):
wantAck=True, wantAck=True,
channelIndex=channelIndex, channelIndex=channelIndex,
onResponse=interface.getNode(args.dest, False, **getNode_kwargs).onAckNak, onResponse=interface.getNode(args.dest, False, **getNode_kwargs).onAckNak,
portNum=portnums_pb2.PortNum.PRIVATE_APP if args.private else portnums_pb2.PortNum.TEXT_MESSAGE_APP
) )
else: else:
meshtastic.util.our_exit( meshtastic.util.our_exit(
@@ -714,12 +716,16 @@ def onConnected(interface):
closeNow = True closeNow = True
export_config(interface) export_config(interface)
if args.seturl: if args.ch_set_url:
closeNow = True closeNow = True
interface.getNode(args.dest, **getNode_kwargs).setURL(args.seturl) interface.getNode(args.dest, **getNode_kwargs).setURL(args.ch_set_url, addOnly=False)
# handle changing channels # handle changing channels
if args.ch_add_url:
closeNow = True
interface.getNode(args.dest, **getNode_kwargs).setURL(args.ch_add_url, addOnly=True)
if args.ch_add: if args.ch_add:
channelIndex = mt_config.channel_index channelIndex = mt_config.channel_index
if channelIndex is not None: if channelIndex is not None:
@@ -921,7 +927,11 @@ def onConnected(interface):
if args.dest != BROADCAST_ADDR: if args.dest != BROADCAST_ADDR:
print("Showing node list of a remote node is not supported.") print("Showing node list of a remote node is not supported.")
return return
interface.showNodes() interface.showNodes(True, args.show_fields)
if args.show_fields and not args.nodes:
print("--show-fields can only be used with --nodes")
return
if args.qr or args.qr_all: if args.qr or args.qr_all:
closeNow = True closeNow = True
@@ -1245,6 +1255,19 @@ def common():
noProto=args.noproto, noProto=args.noproto,
noNodes=args.no_nodes, noNodes=args.no_nodes,
) )
except FileNotFoundError:
# Handle the case where the serial device is not found
message = (
f"File Not Found Error:\n"
)
message += f" The serial device at '{args.port}' was not found.\n"
message += " Please check the following:\n"
message += " 1. Is the device connected properly?\n"
message += " 2. Is the correct serial port specified?\n"
message += " 3. Are the necessary drivers installed?\n"
message += " 4. Are you using a **power-only USB cable**? A power-only cable cannot transmit data.\n"
message += " Ensure you are using a **data-capable USB cable**.\n"
meshtastic.util.our_exit(message, 1)
except PermissionError as ex: except PermissionError as ex:
username = os.getlogin() username = os.getlogin()
message = "Permission Error:\n" message = "Permission Error:\n"
@@ -1255,6 +1278,12 @@ def common():
message += " After running that command, log out and re-login for it to take effect.\n" message += " After running that command, log out and re-login for it to take effect.\n"
message += f"Error was:{ex}" message += f"Error was:{ex}"
meshtastic.util.our_exit(message) meshtastic.util.our_exit(message)
except OSError as ex:
message = f"OS Error:\n"
message += " The serial device couldn't be opened, it might be in use by another process.\n"
message += " Please close any applications or webpages that may be using the device and try again.\n"
message += f"\nOriginal error: {ex}"
meshtastic.util.our_exit(message)
if client.devPath is None: if client.devPath is None:
try: try:
client = meshtastic.tcp_interface.TCPInterface( client = meshtastic.tcp_interface.TCPInterface(
@@ -1342,7 +1371,8 @@ def addSelectionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentParser
group.add_argument( group.add_argument(
"--dest", "--dest",
help="The destination node id for any sent commands, if not set '^all' or '^local' is assumed as appropriate", help="The destination node id for any sent commands. If not set '^all' or '^local' is assumed."
"Use the node ID with a '!' or '0x' prefix or the node number.",
default=None, default=None,
metavar="!xxxxxxxx", metavar="!xxxxxxxx",
) )
@@ -1489,7 +1519,20 @@ def addConfigArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
"--set-ham", help="Set licensed Ham ID and turn off encryption", action="store" "--set-ham", help="Set licensed Ham ID and turn off encryption", action="store"
) )
group.add_argument("--seturl", help="Set a channel URL", action="store") group.add_argument(
"--ch-set-url", "--seturl",
help="Set all channels and set LoRa config from a supplied URL",
metavar="URL",
action="store"
)
group.add_argument(
"--ch-add-url",
help="Add secondary channels and set LoRa config from a supplied URL",
metavar="URL",
default=None,
)
return parser return parser
@@ -1627,6 +1670,13 @@ def addLocalActionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPars
action="store_true", action="store_true",
) )
group.add_argument(
"--show-fields",
help="Specify fields to show (comma-separated) when using --nodes",
type=lambda s: s.split(','),
default=None
)
return parser return parser
def addRemoteActionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: def addRemoteActionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
@@ -1638,15 +1688,21 @@ def addRemoteActionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPar
group.add_argument( group.add_argument(
"--sendtext", "--sendtext",
help="Send a text message. Can specify a destination '--dest' and/or channel index '--ch-index'.", help="Send a text message. Can specify a destination '--dest', use of PRIVATE_APP port '--private', and/or channel index '--ch-index'.",
metavar="TEXT", metavar="TEXT",
) )
group.add_argument(
"--private",
help="Optional argument for sending text messages to the PRIVATE_APP port. Use in combination with --sendtext.",
action="store_true"
)
group.add_argument( group.add_argument(
"--traceroute", "--traceroute",
help="Traceroute from connected node to a destination. " help="Traceroute from connected node to a destination. "
"You need pass the destination ID as argument, like " "You need pass the destination ID as argument, like "
"this: '--traceroute !ba4bf9d0' " "this: '--traceroute !ba4bf9d0' | '--traceroute 0xba4bf9d0'"
"Only nodes with a shared channel can be traced.", "Only nodes with a shared channel can be traced.",
metavar="!xxxxxxxx", metavar="!xxxxxxxx",
) )
@@ -1727,27 +1783,32 @@ def addRemoteAdminArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPars
group.add_argument( group.add_argument(
"--remove-node", "--remove-node",
help="Tell the destination node to remove a specific node from its DB, by node number or ID", help="Tell the destination node to remove a specific node from its NodeDB. "
"Use the node ID with a '!' or '0x' prefix or the node number.",
metavar="!xxxxxxxx" metavar="!xxxxxxxx"
) )
group.add_argument( group.add_argument(
"--set-favorite-node", "--set-favorite-node",
help="Tell the destination node to set the specified node to be favorited on the NodeDB on the devicein its DB, by number or ID", help="Tell the destination node to set the specified node to be favorited on the NodeDB. "
"Use the node ID with a '!' or '0x' prefix or the node number.",
metavar="!xxxxxxxx" metavar="!xxxxxxxx"
) )
group.add_argument( group.add_argument(
"--remove-favorite-node", "--remove-favorite-node",
help="Tell the destination node to set the specified node to be un-favorited on the NodeDB on the device, by number or ID", help="Tell the destination node to set the specified node to be un-favorited on the NodeDB. "
"Use the node ID with a '!' or '0x' prefix or the node number.",
metavar="!xxxxxxxx" metavar="!xxxxxxxx"
) )
group.add_argument( group.add_argument(
"--set-ignored-node", "--set-ignored-node",
help="Tell the destination node to set the specified node to be ignored on the NodeDB on the devicein its DB, by number or ID", help="Tell the destination node to set the specified node to be ignored on the NodeDB. "
"Use the node ID with a '!' or '0x' prefix or the node number.",
metavar="!xxxxxxxx" metavar="!xxxxxxxx"
) )
group.add_argument( group.add_argument(
"--remove-ignored-node", "--remove-ignored-node",
help="Tell the destination node to set the specified node to be un-ignored on the NodeDB on the device, by number or ID", help="Tell the destination node to set the specified node to be un-ignored on the NodeDB. "
"Use the node ID with a '!' or '0x' prefix or the node number.",
metavar="!xxxxxxxx" metavar="!xxxxxxxx"
) )
group.add_argument( group.add_argument(

View File

@@ -222,9 +222,42 @@ class MeshInterface: # pylint: disable=R0902
return infos return infos
def showNodes( def showNodes(
self, includeSelf: bool = True self, includeSelf: bool = True, showFields: Optional[List[str]] = None
) -> str: # pylint: disable=W0613 ) -> str: # pylint: disable=W0613
"""Show table summary of nodes in mesh""" """Show table summary of nodes in mesh
Args:
includeSelf (bool): Include ourself in the output?
showFields (List[str]): List of fields to show in output
"""
def get_human_readable(name):
name_map = {
"user.longName": "User",
"user.id": "ID",
"user.shortName": "AKA",
"user.hwModel": "Hardware",
"user.publicKey": "Pubkey",
"user.role": "Role",
"position.latitude": "Latitude",
"position.longitude": "Longitude",
"position.altitude": "Altitude",
"deviceMetrics.batteryLevel": "Battery",
"deviceMetrics.channelUtilization": "Channel util.",
"deviceMetrics.airUtilTx": "Tx air util.",
"snr": "SNR",
"hopsAway": "Hops",
"channel": "Channel",
"lastHeard": "LastHeard",
"since": "Since",
}
if name in name_map:
return name_map.get(name) # Default to a formatted guess
else:
return name
def formatFloat(value, precision=2, unit="") -> Optional[str]: def formatFloat(value, precision=2, unit="") -> Optional[str]:
"""Format a float value with precision.""" """Format a float value with precision."""
@@ -246,6 +279,29 @@ class MeshInterface: # pylint: disable=R0902
return None # not handling a timestamp from the future return None # not handling a timestamp from the future
return _timeago(delta_secs) return _timeago(delta_secs)
def getNestedValue(node_dict: Dict[str, Any], key_path: str) -> Any:
if key_path.index(".") < 0:
logging.debug("getNestedValue was called without a nested path.")
return None
keys = key_path.split(".")
value: Optional[Union[str, dict]] = node_dict
for key in keys:
if isinstance(value, dict):
value = value.get(key)
else:
return None
return value
if showFields is None or len(showFields) == 0:
# The default set of fields to show (e.g., the status quo)
showFields = ["N", "user.longName", "user.id", "user.shortName", "user.hwModel", "user.publicKey",
"user.role", "position.latitude", "position.longitude", "position.altitude",
"deviceMetrics.batteryLevel", "deviceMetrics.channelUtilization",
"deviceMetrics.airUtilTx", "snr", "hopsAway", "channel", "lastHeard", "since"]
else:
# Always at least include the row number.
showFields.insert(0, "N")
rows: List[Dict[str, Any]] = [] rows: List[Dict[str, Any]] = []
if self.nodesByNum: if self.nodesByNum:
logging.debug(f"self.nodes:{self.nodes}") logging.debug(f"self.nodes:{self.nodes}")
@@ -254,65 +310,60 @@ class MeshInterface: # pylint: disable=R0902
continue continue
presumptive_id = f"!{node['num']:08x}" presumptive_id = f"!{node['num']:08x}"
row = {
"N": 0,
"User": f"Meshtastic {presumptive_id[-4:]}",
"ID": presumptive_id,
}
user = node.get("user") # This allows the user to specify fields that wouldn't otherwise be included.
if user: fields = {}
row.update( for field in showFields:
{ if "." in field:
"User": user.get("longName", "N/A"), raw_value = getNestedValue(node, field)
"AKA": user.get("shortName", "N/A"), else:
"ID": user["id"], # The "since" column is synthesized, it's not retrieved from the device. Get the
"Hardware": user.get("hwModel", "UNSET"), # lastHeard value here, and then we'll format it properly below.
"Pubkey": user.get("publicKey", "UNSET"), if field == "since":
} raw_value = node.get("lastHeard")
)
pos = node.get("position")
if pos:
row.update(
{
"Latitude": formatFloat(pos.get("latitude"), 4, "°"),
"Longitude": formatFloat(pos.get("longitude"), 4, "°"),
"Altitude": formatFloat(pos.get("altitude"), 0, " m"),
}
)
metrics = node.get("deviceMetrics")
if metrics:
batteryLevel = metrics.get("batteryLevel")
if batteryLevel is not None:
if batteryLevel == 0:
batteryString = "Powered"
else: else:
batteryString = str(batteryLevel) + "%" raw_value = node.get(field)
row.update({"Battery": batteryString})
row.update(
{
"Channel util.": formatFloat(
metrics.get("channelUtilization"), 2, "%"
),
"Tx air util.": formatFloat(
metrics.get("airUtilTx"), 2, "%"
),
}
)
row.update( formatted_value: Optional[str] = ""
{
"SNR": formatFloat(node.get("snr"), 2, " dB"),
"Hops": node.get("hopsAway", "?"),
"Channel": node.get("channel", 0),
"LastHeard": getLH(node.get("lastHeard")),
"Since": getTimeAgo(node.get("lastHeard")),
}
)
rows.append(row) # Some of these need special formatting or processing.
if field == "channel":
if raw_value is None:
formatted_value = "0"
elif field == "deviceMetrics.channelUtilization":
formatted_value = formatFloat(raw_value, 2, "%")
elif field == "deviceMetrics.airUtilTx":
formatted_value = formatFloat(raw_value, 2, "%")
elif field == "deviceMetrics.batteryLevel":
if raw_value in (0, 101):
formatted_value = "Powered"
else:
formatted_value = formatFloat(raw_value, 0, "%")
elif field == "lastHeard":
formatted_value = getLH(raw_value)
elif field == "position.latitude":
formatted_value = formatFloat(raw_value, 4, "°")
elif field == "position.longitude":
formatted_value = formatFloat(raw_value, 4, "°")
elif field == "position.altitude":
formatted_value = formatFloat(raw_value, 0, "m")
elif field == "since":
formatted_value = getTimeAgo(raw_value) or "N/A"
elif field == "snr":
formatted_value = formatFloat(raw_value, 0, " dB")
elif field == "user.shortName":
formatted_value = raw_value if raw_value is not None else f'Meshtastic {presumptive_id[-4:]}'
elif field == "user.id":
formatted_value = raw_value if raw_value is not None else presumptive_id
else:
formatted_value = raw_value # No special formatting
fields[field] = formatted_value
# Filter out any field in the data set that was not specified.
filteredData = {get_human_readable(k): v for k, v in fields.items() if k in showFields}
filteredData.update({get_human_readable(k): v for k, v in fields.items()})
rows.append(filteredData)
rows.sort(key=lambda r: r.get("LastHeard") or "0000", reverse=True) rows.sort(key=lambda r: r.get("LastHeard") or "0000", reverse=True)
for i, row in enumerate(rows): for i, row in enumerate(rows):
@@ -344,7 +395,7 @@ class MeshInterface: # pylint: disable=R0902
if new_index != last_index: if new_index != last_index:
retries_left = requestChannelAttempts - 1 retries_left = requestChannelAttempts - 1
if retries_left <= 0: if retries_left <= 0:
our_exit(f"Error: Timed out waiting for channels, giving up") our_exit("Error: Timed out waiting for channels, giving up")
print("Timed out trying to retrieve channel info, retrying") print("Timed out trying to retrieve channel info, retrying")
n.requestChannels(startingIndex=new_index) n.requestChannels(startingIndex=new_index)
last_index = new_index last_index = new_index
@@ -360,6 +411,7 @@ class MeshInterface: # pylint: disable=R0902
wantResponse: bool = False, wantResponse: bool = False,
onResponse: Optional[Callable[[dict], Any]] = None, onResponse: Optional[Callable[[dict], Any]] = None,
channelIndex: int = 0, channelIndex: int = 0,
portNum: portnums_pb2.PortNum.ValueType = portnums_pb2.PortNum.TEXT_MESSAGE_APP
): ):
"""Send a utf8 string to some other node, if the node has a display it """Send a utf8 string to some other node, if the node has a display it
will also be shown on the device. will also be shown on the device.
@@ -370,12 +422,12 @@ class MeshInterface: # pylint: disable=R0902
Keyword Arguments: Keyword Arguments:
destinationId {nodeId or nodeNum} -- where to send this destinationId {nodeId or nodeNum} -- where to send this
message (default: {BROADCAST_ADDR}) message (default: {BROADCAST_ADDR})
portNum -- the application portnum (similar to IP port numbers)
of the destination, see portnums.proto for a list
wantAck -- True if you want the message sent in a reliable manner wantAck -- True if you want the message sent in a reliable manner
(with retries and ack/nak provided for delivery) (with retries and ack/nak provided for delivery)
wantResponse -- True if you want the service on the other side to wantResponse -- True if you want the service on the other side to
send an application layer response send an application layer response
portNum -- the application portnum (similar to IP port numbers)
of the destination, see portnums.proto for a list
Returns the sent packet. The id field will be populated in this packet Returns the sent packet. The id field will be populated in this packet
and can be used to track future message acks/naks. and can be used to track future message acks/naks.
@@ -384,7 +436,7 @@ class MeshInterface: # pylint: disable=R0902
return self.sendData( return self.sendData(
text.encode("utf-8"), text.encode("utf-8"),
destinationId, destinationId,
portNum=portnums_pb2.PortNum.TEXT_MESSAGE_APP, portNum=portNum,
wantAck=wantAck, wantAck=wantAck,
wantResponse=wantResponse, wantResponse=wantResponse,
onResponse=onResponse, onResponse=onResponse,
@@ -891,8 +943,10 @@ class MeshInterface: # pylint: disable=R0902
else: else:
our_exit("Warning: No myInfo found.") our_exit("Warning: No myInfo found.")
# A simple hex style nodeid - we can parse this without needing the DB # A simple hex style nodeid - we can parse this without needing the DB
elif destinationId.startswith("!"): elif isinstance(destinationId, str) and len(destinationId) >= 8:
nodeNum = int(destinationId[1:], 16) # assuming some form of node id string such as !1234578 or 0x12345678
# always grab the last 8 items of the hexadecimal id str and parse to integer
nodeNum = int(destinationId[-8:], 16)
else: else:
if self.nodes: if self.nodes:
node = self.nodes.get(destinationId) node = self.nodes.get(destinationId)
@@ -926,7 +980,7 @@ class MeshInterface: # pylint: disable=R0902
toRadio.packet.CopyFrom(meshPacket) toRadio.packet.CopyFrom(meshPacket)
if self.noProto: if self.noProto:
logging.warning( logging.warning(
f"Not sending packet because protocol use is disabled by noProto" "Not sending packet because protocol use is disabled by noProto"
) )
else: else:
logging.debug(f"Sending packet: {stripnl(meshPacket)}") logging.debug(f"Sending packet: {stripnl(meshPacket)}")
@@ -1115,7 +1169,7 @@ class MeshInterface: # pylint: disable=R0902
"""Send a ToRadio protobuf to the device""" """Send a ToRadio protobuf to the device"""
if self.noProto: if self.noProto:
logging.warning( logging.warning(
f"Not sending packet because protocol use is disabled by noProto" "Not sending packet because protocol use is disabled by noProto"
) )
else: else:
# logging.debug(f"Sending toRadio: {stripnl(toRadio)}") # logging.debug(f"Sending toRadio: {stripnl(toRadio)}")

View File

@@ -337,14 +337,19 @@ class Node:
s = s.replace("=", "").replace("+", "-").replace("/", "_") s = s.replace("=", "").replace("+", "-").replace("/", "_")
return f"https://meshtastic.org/e/#{s}" return f"https://meshtastic.org/e/#{s}"
def setURL(self, url): def setURL(self, url: str, addOnly: bool = False):
"""Set mesh network URL""" """Set mesh network URL"""
if self.localConfig is None: if self.localConfig is None or self.channels is None:
our_exit("Warning: No Config has been read") our_exit("Warning: config or channels not loaded")
# URLs are of the form https://meshtastic.org/d/#{base64_channel_set} # URLs are of the form https://meshtastic.org/d/#{base64_channel_set}
# Split on '/#' to find the base64 encoded channel settings # Split on '/#' to find the base64 encoded channel settings
splitURL = url.split("/#") if addOnly:
splitURL = url.split("/?add=true#")
else:
splitURL = url.split("/#")
if len(splitURL) == 1:
our_exit(f"Warning: Invalid URL '{url}'")
b64 = splitURL[-1] b64 = splitURL[-1]
# We normally strip padding to make for a shorter URL, but the python parser doesn't like # We normally strip padding to make for a shorter URL, but the python parser doesn't like
@@ -361,20 +366,36 @@ class Node:
if len(channelSet.settings) == 0: if len(channelSet.settings) == 0:
our_exit("Warning: There were no settings.") our_exit("Warning: There were no settings.")
i = 0 if addOnly:
for chs in channelSet.settings: # Add new channels with names not already present
ch = channel_pb2.Channel() # Don't change existing channels
ch.role = ( for chs in channelSet.settings:
channel_pb2.Channel.Role.PRIMARY channelExists = self.getChannelByName(chs.name)
if i == 0 if channelExists or chs.name == "":
else channel_pb2.Channel.Role.SECONDARY print(f"Ignoring existing or empty channel \"{chs.name}\" from add URL")
) continue
ch.index = i ch = self.getDisabledChannel()
ch.settings.CopyFrom(chs) if not ch:
self.channels[ch.index] = ch our_exit("Warning: No free channels were found")
logging.debug(f"Channel i:{i} ch:{ch}") ch.settings.CopyFrom(chs)
self.writeChannel(ch.index) ch.role = channel_pb2.Channel.Role.SECONDARY
i = i + 1 print(f"Adding new channel '{chs.name}' to device")
self.writeChannel(ch.index)
else:
i = 0
for chs in channelSet.settings:
ch = channel_pb2.Channel()
ch.role = (
channel_pb2.Channel.Role.PRIMARY
if i == 0
else channel_pb2.Channel.Role.SECONDARY
)
ch.index = i
ch.settings.CopyFrom(chs)
self.channels[ch.index] = ch
logging.debug(f"Channel i:{i} ch:{ch}")
self.writeChannel(ch.index)
i = i + 1
p = admin_pb2.AdminMessage() p = admin_pb2.AdminMessage()
p.set_config.lora.CopyFrom(channelSet.lora_config) p.set_config.lora.CopyFrom(channelSet.lora_config)

View File

File diff suppressed because one or more lines are too long

View File

@@ -1118,6 +1118,7 @@ class Config(google.protobuf.message.Message):
HEADING_BOLD_FIELD_NUMBER: builtins.int HEADING_BOLD_FIELD_NUMBER: builtins.int
WAKE_ON_TAP_OR_MOTION_FIELD_NUMBER: builtins.int WAKE_ON_TAP_OR_MOTION_FIELD_NUMBER: builtins.int
COMPASS_ORIENTATION_FIELD_NUMBER: builtins.int COMPASS_ORIENTATION_FIELD_NUMBER: builtins.int
USE_12H_CLOCK_FIELD_NUMBER: builtins.int
screen_on_secs: builtins.int screen_on_secs: builtins.int
""" """
Number of seconds the screen stays on after pressing the user button or receiving a message Number of seconds the screen stays on after pressing the user button or receiving a message
@@ -1165,6 +1166,11 @@ class Config(google.protobuf.message.Message):
""" """
Indicates how to rotate or invert the compass output to accurate display on the display. Indicates how to rotate or invert the compass output to accurate display on the display.
""" """
use_12h_clock: builtins.bool
"""
If false (default), the device will display the time in 24-hour format on screen.
If true, the device will display the time in 12-hour format on screen.
"""
def __init__( def __init__(
self, self,
*, *,
@@ -1179,8 +1185,9 @@ class Config(google.protobuf.message.Message):
heading_bold: builtins.bool = ..., heading_bold: builtins.bool = ...,
wake_on_tap_or_motion: builtins.bool = ..., wake_on_tap_or_motion: builtins.bool = ...,
compass_orientation: global___Config.DisplayConfig.CompassOrientation.ValueType = ..., compass_orientation: global___Config.DisplayConfig.CompassOrientation.ValueType = ...,
use_12h_clock: builtins.bool = ...,
) -> None: ... ) -> None: ...
def ClearField(self, field_name: typing.Literal["auto_screen_carousel_secs", b"auto_screen_carousel_secs", "compass_north_top", b"compass_north_top", "compass_orientation", b"compass_orientation", "displaymode", b"displaymode", "flip_screen", b"flip_screen", "gps_format", b"gps_format", "heading_bold", b"heading_bold", "oled", b"oled", "screen_on_secs", b"screen_on_secs", "units", b"units", "wake_on_tap_or_motion", b"wake_on_tap_or_motion"]) -> None: ... def ClearField(self, field_name: typing.Literal["auto_screen_carousel_secs", b"auto_screen_carousel_secs", "compass_north_top", b"compass_north_top", "compass_orientation", b"compass_orientation", "displaymode", b"displaymode", "flip_screen", b"flip_screen", "gps_format", b"gps_format", "heading_bold", b"heading_bold", "oled", b"oled", "screen_on_secs", b"screen_on_secs", "units", b"units", "use_12h_clock", b"use_12h_clock", "wake_on_tap_or_motion", b"wake_on_tap_or_motion"]) -> None: ...
@typing.final @typing.final
class LoRaConfig(google.protobuf.message.Message): class LoRaConfig(google.protobuf.message.Message):

View File

File diff suppressed because one or more lines are too long

View File

@@ -397,6 +397,11 @@ class _HardwareModelEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._
Mesh-Tab, esp32 based Mesh-Tab, esp32 based
https://github.com/valzzu/Mesh-Tab https://github.com/valzzu/Mesh-Tab
""" """
MESHLINK: _HardwareModel.ValueType # 87
"""
MeshLink board developed by LoraItalia. NRF52840, eByte E22900M22S (Will also come with other frequencies), 25w MPPT solar charger (5v,12v,18v selectable), support for gps, buzzer, oled or e-ink display, 10 gpios, hardware watchdog
https://www.loraitalia.it
"""
PRIVATE_HW: _HardwareModel.ValueType # 255 PRIVATE_HW: _HardwareModel.ValueType # 255
""" """
------------------------------------------------------------------------------------------------------------------------------------------ ------------------------------------------------------------------------------------------------------------------------------------------
@@ -777,6 +782,11 @@ MESH_TAB: HardwareModel.ValueType # 86
Mesh-Tab, esp32 based Mesh-Tab, esp32 based
https://github.com/valzzu/Mesh-Tab https://github.com/valzzu/Mesh-Tab
""" """
MESHLINK: HardwareModel.ValueType # 87
"""
MeshLink board developed by LoraItalia. NRF52840, eByte E22900M22S (Will also come with other frequencies), 25w MPPT solar charger (5v,12v,18v selectable), support for gps, buzzer, oled or e-ink display, 10 gpios, hardware watchdog
https://www.loraitalia.it
"""
PRIVATE_HW: HardwareModel.ValueType # 255 PRIVATE_HW: HardwareModel.ValueType # 255
""" """
------------------------------------------------------------------------------------------------------------------------------------------ ------------------------------------------------------------------------------------------------------------------------------------------

View File

@@ -927,7 +927,7 @@ class ModuleConfig(google.protobuf.message.Message):
@typing.final @typing.final
class CannedMessageConfig(google.protobuf.message.Message): class CannedMessageConfig(google.protobuf.message.Message):
""" """
TODO: REPLACE Canned Messages Module Config
""" """
DESCRIPTOR: google.protobuf.descriptor.Descriptor DESCRIPTOR: google.protobuf.descriptor.Descriptor

View File

File diff suppressed because one or more lines are too long

View File

@@ -163,6 +163,10 @@ class _TelemetrySensorTypeEnumTypeWrapper(google.protobuf.internal.enum_type_wra
""" """
High accuracy current and voltage High accuracy current and voltage
""" """
DFROBOT_RAIN: _TelemetrySensorType.ValueType # 35
"""
DFRobot Gravity tipping bucket rain gauge
"""
class TelemetrySensorType(_TelemetrySensorType, metaclass=_TelemetrySensorTypeEnumTypeWrapper): class TelemetrySensorType(_TelemetrySensorType, metaclass=_TelemetrySensorTypeEnumTypeWrapper):
""" """
@@ -309,6 +313,10 @@ INA226: TelemetrySensorType.ValueType # 34
""" """
High accuracy current and voltage High accuracy current and voltage
""" """
DFROBOT_RAIN: TelemetrySensorType.ValueType # 35
"""
DFRobot Gravity tipping bucket rain gauge
"""
global___TelemetrySensorType = TelemetrySensorType global___TelemetrySensorType = TelemetrySensorType
@typing.final @typing.final
@@ -394,6 +402,8 @@ class EnvironmentMetrics(google.protobuf.message.Message):
WIND_GUST_FIELD_NUMBER: builtins.int WIND_GUST_FIELD_NUMBER: builtins.int
WIND_LULL_FIELD_NUMBER: builtins.int WIND_LULL_FIELD_NUMBER: builtins.int
RADIATION_FIELD_NUMBER: builtins.int RADIATION_FIELD_NUMBER: builtins.int
RAINFALL_1H_FIELD_NUMBER: builtins.int
RAINFALL_24H_FIELD_NUMBER: builtins.int
temperature: builtins.float temperature: builtins.float
""" """
Temperature measured Temperature measured
@@ -468,6 +478,14 @@ class EnvironmentMetrics(google.protobuf.message.Message):
""" """
Radiation in µR/h Radiation in µR/h
""" """
rainfall_1h: builtins.float
"""
Rainfall in the last hour in mm
"""
rainfall_24h: builtins.float
"""
Rainfall in the last 24 hours in mm
"""
def __init__( def __init__(
self, self,
*, *,
@@ -489,9 +507,11 @@ class EnvironmentMetrics(google.protobuf.message.Message):
wind_gust: builtins.float | None = ..., wind_gust: builtins.float | None = ...,
wind_lull: builtins.float | None = ..., wind_lull: builtins.float | None = ...,
radiation: builtins.float | None = ..., radiation: builtins.float | None = ...,
rainfall_1h: builtins.float | None = ...,
rainfall_24h: builtins.float | None = ...,
) -> None: ... ) -> None: ...
def HasField(self, field_name: typing.Literal["_barometric_pressure", b"_barometric_pressure", "_current", b"_current", "_distance", b"_distance", "_gas_resistance", b"_gas_resistance", "_iaq", b"_iaq", "_ir_lux", b"_ir_lux", "_lux", b"_lux", "_radiation", b"_radiation", "_relative_humidity", b"_relative_humidity", "_temperature", b"_temperature", "_uv_lux", b"_uv_lux", "_voltage", b"_voltage", "_weight", b"_weight", "_white_lux", b"_white_lux", "_wind_direction", b"_wind_direction", "_wind_gust", b"_wind_gust", "_wind_lull", b"_wind_lull", "_wind_speed", b"_wind_speed", "barometric_pressure", b"barometric_pressure", "current", b"current", "distance", b"distance", "gas_resistance", b"gas_resistance", "iaq", b"iaq", "ir_lux", b"ir_lux", "lux", b"lux", "radiation", b"radiation", "relative_humidity", b"relative_humidity", "temperature", b"temperature", "uv_lux", b"uv_lux", "voltage", b"voltage", "weight", b"weight", "white_lux", b"white_lux", "wind_direction", b"wind_direction", "wind_gust", b"wind_gust", "wind_lull", b"wind_lull", "wind_speed", b"wind_speed"]) -> builtins.bool: ... def HasField(self, field_name: typing.Literal["_barometric_pressure", b"_barometric_pressure", "_current", b"_current", "_distance", b"_distance", "_gas_resistance", b"_gas_resistance", "_iaq", b"_iaq", "_ir_lux", b"_ir_lux", "_lux", b"_lux", "_radiation", b"_radiation", "_rainfall_1h", b"_rainfall_1h", "_rainfall_24h", b"_rainfall_24h", "_relative_humidity", b"_relative_humidity", "_temperature", b"_temperature", "_uv_lux", b"_uv_lux", "_voltage", b"_voltage", "_weight", b"_weight", "_white_lux", b"_white_lux", "_wind_direction", b"_wind_direction", "_wind_gust", b"_wind_gust", "_wind_lull", b"_wind_lull", "_wind_speed", b"_wind_speed", "barometric_pressure", b"barometric_pressure", "current", b"current", "distance", b"distance", "gas_resistance", b"gas_resistance", "iaq", b"iaq", "ir_lux", b"ir_lux", "lux", b"lux", "radiation", b"radiation", "rainfall_1h", b"rainfall_1h", "rainfall_24h", b"rainfall_24h", "relative_humidity", b"relative_humidity", "temperature", b"temperature", "uv_lux", b"uv_lux", "voltage", b"voltage", "weight", b"weight", "white_lux", b"white_lux", "wind_direction", b"wind_direction", "wind_gust", b"wind_gust", "wind_lull", b"wind_lull", "wind_speed", b"wind_speed"]) -> builtins.bool: ...
def ClearField(self, field_name: typing.Literal["_barometric_pressure", b"_barometric_pressure", "_current", b"_current", "_distance", b"_distance", "_gas_resistance", b"_gas_resistance", "_iaq", b"_iaq", "_ir_lux", b"_ir_lux", "_lux", b"_lux", "_radiation", b"_radiation", "_relative_humidity", b"_relative_humidity", "_temperature", b"_temperature", "_uv_lux", b"_uv_lux", "_voltage", b"_voltage", "_weight", b"_weight", "_white_lux", b"_white_lux", "_wind_direction", b"_wind_direction", "_wind_gust", b"_wind_gust", "_wind_lull", b"_wind_lull", "_wind_speed", b"_wind_speed", "barometric_pressure", b"barometric_pressure", "current", b"current", "distance", b"distance", "gas_resistance", b"gas_resistance", "iaq", b"iaq", "ir_lux", b"ir_lux", "lux", b"lux", "radiation", b"radiation", "relative_humidity", b"relative_humidity", "temperature", b"temperature", "uv_lux", b"uv_lux", "voltage", b"voltage", "weight", b"weight", "white_lux", b"white_lux", "wind_direction", b"wind_direction", "wind_gust", b"wind_gust", "wind_lull", b"wind_lull", "wind_speed", b"wind_speed"]) -> None: ... def ClearField(self, field_name: typing.Literal["_barometric_pressure", b"_barometric_pressure", "_current", b"_current", "_distance", b"_distance", "_gas_resistance", b"_gas_resistance", "_iaq", b"_iaq", "_ir_lux", b"_ir_lux", "_lux", b"_lux", "_radiation", b"_radiation", "_rainfall_1h", b"_rainfall_1h", "_rainfall_24h", b"_rainfall_24h", "_relative_humidity", b"_relative_humidity", "_temperature", b"_temperature", "_uv_lux", b"_uv_lux", "_voltage", b"_voltage", "_weight", b"_weight", "_white_lux", b"_white_lux", "_wind_direction", b"_wind_direction", "_wind_gust", b"_wind_gust", "_wind_lull", b"_wind_lull", "_wind_speed", b"_wind_speed", "barometric_pressure", b"barometric_pressure", "current", b"current", "distance", b"distance", "gas_resistance", b"gas_resistance", "iaq", b"iaq", "ir_lux", b"ir_lux", "lux", b"lux", "radiation", b"radiation", "rainfall_1h", b"rainfall_1h", "rainfall_24h", b"rainfall_24h", "relative_humidity", b"relative_humidity", "temperature", b"temperature", "uv_lux", b"uv_lux", "voltage", b"voltage", "weight", b"weight", "white_lux", b"white_lux", "wind_direction", b"wind_direction", "wind_gust", b"wind_gust", "wind_lull", b"wind_lull", "wind_speed", b"wind_speed"]) -> None: ...
@typing.overload @typing.overload
def WhichOneof(self, oneof_group: typing.Literal["_barometric_pressure", b"_barometric_pressure"]) -> typing.Literal["barometric_pressure"] | None: ... def WhichOneof(self, oneof_group: typing.Literal["_barometric_pressure", b"_barometric_pressure"]) -> typing.Literal["barometric_pressure"] | None: ...
@typing.overload @typing.overload
@@ -509,6 +529,10 @@ class EnvironmentMetrics(google.protobuf.message.Message):
@typing.overload @typing.overload
def WhichOneof(self, oneof_group: typing.Literal["_radiation", b"_radiation"]) -> typing.Literal["radiation"] | None: ... def WhichOneof(self, oneof_group: typing.Literal["_radiation", b"_radiation"]) -> typing.Literal["radiation"] | None: ...
@typing.overload @typing.overload
def WhichOneof(self, oneof_group: typing.Literal["_rainfall_1h", b"_rainfall_1h"]) -> typing.Literal["rainfall_1h"] | None: ...
@typing.overload
def WhichOneof(self, oneof_group: typing.Literal["_rainfall_24h", b"_rainfall_24h"]) -> typing.Literal["rainfall_24h"] | None: ...
@typing.overload
def WhichOneof(self, oneof_group: typing.Literal["_relative_humidity", b"_relative_humidity"]) -> typing.Literal["relative_humidity"] | None: ... def WhichOneof(self, oneof_group: typing.Literal["_relative_humidity", b"_relative_humidity"]) -> typing.Literal["relative_humidity"] | None: ...
@typing.overload @typing.overload
def WhichOneof(self, oneof_group: typing.Literal["_temperature", b"_temperature"]) -> typing.Literal["temperature"] | None: ... def WhichOneof(self, oneof_group: typing.Literal["_temperature", b"_temperature"]) -> typing.Literal["temperature"] | None: ...

View File

@@ -408,8 +408,8 @@ def test_main_nodes(capsys):
iface = MagicMock(autospec=SerialInterface) iface = MagicMock(autospec=SerialInterface)
def mock_showNodes(): def mock_showNodes(includeSelf, showFields):
print("inside mocked showNodes") print(f"inside mocked showNodes: {includeSelf} {showFields}")
iface.showNodes.side_effect = mock_showNodes iface.showNodes.side_effect = mock_showNodes
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface) as mo: with patch("meshtastic.serial_interface.SerialInterface", return_value=iface) as mo:
@@ -593,10 +593,10 @@ def test_main_sendtext(capsys):
iface = MagicMock(autospec=SerialInterface) iface = MagicMock(autospec=SerialInterface)
def mock_sendText( def mock_sendText(
text, dest, wantAck=False, wantResponse=False, onResponse=None, channelIndex=0 text, dest, wantAck=False, wantResponse=False, onResponse=None, channelIndex=0, portNum=0
): ):
print("inside mocked sendText") print("inside mocked sendText")
print(f"{text} {dest} {wantAck} {wantResponse} {channelIndex}") print(f"{text} {dest} {wantAck} {wantResponse} {channelIndex} {portNum}")
iface.sendText.side_effect = mock_sendText iface.sendText.side_effect = mock_sendText
@@ -620,10 +620,10 @@ def test_main_sendtext_with_channel(capsys):
iface = MagicMock(autospec=SerialInterface) iface = MagicMock(autospec=SerialInterface)
def mock_sendText( def mock_sendText(
text, dest, wantAck=False, wantResponse=False, onResponse=None, channelIndex=0 text, dest, wantAck=False, wantResponse=False, onResponse=None, channelIndex=0, portNum=0
): ):
print("inside mocked sendText") print("inside mocked sendText")
print(f"{text} {dest} {wantAck} {wantResponse} {channelIndex}") print(f"{text} {dest} {wantAck} {wantResponse} {channelIndex} {portNum}")
iface.sendText.side_effect = mock_sendText iface.sendText.side_effect = mock_sendText

View File

@@ -270,7 +270,7 @@ def test_setURL_empty_url(capsys):
assert pytest_wrapped_e.type == SystemExit assert pytest_wrapped_e.type == SystemExit
assert pytest_wrapped_e.value.code == 1 assert pytest_wrapped_e.value.code == 1
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert re.search(r"Warning: There were no settings.", out, re.MULTILINE) assert re.search(r"Warning: config or channels not loaded", out, re.MULTILINE)
assert err == "" assert err == ""
@@ -304,7 +304,7 @@ def test_setURL_valid_URL_but_no_settings(capsys):
assert pytest_wrapped_e.type == SystemExit assert pytest_wrapped_e.type == SystemExit
assert pytest_wrapped_e.value.code == 1 assert pytest_wrapped_e.value.code == 1
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert re.search(r"Warning: There were no settings", out, re.MULTILINE) assert re.search(r"Warning: config or channels not loaded", out, re.MULTILINE)
assert err == "" assert err == ""

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "meshtastic" name = "meshtastic"
version = "2.5.10" version = "2.5.12"
description = "Python API & client shell for talking to Meshtastic devices" description = "Python API & client shell for talking to Meshtastic devices"
authors = ["Meshtastic Developers <contact@meshtastic.org>"] authors = ["Meshtastic Developers <contact@meshtastic.org>"]
license = "GPL-3.0-only" license = "GPL-3.0-only"