Files
python/docs/meshtastic/node.html
2021-12-16 13:57:42 -05:00

1095 lines
50 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<meta name="generator" content="pdoc 0.10.0" />
<title>meshtastic.node API documentation</title>
<meta name="description" content="Node class" />
<link rel="preload stylesheet" as="style" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/11.0.1/sanitize.min.css" integrity="sha256-PK9q560IAAa6WVRRh76LtCaI8pjTJ2z11v0miyNNjrs=" crossorigin>
<link rel="preload stylesheet" as="style" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/11.0.1/typography.min.css" integrity="sha256-7l/o7C8jubJiy74VsKTidCy1yBkRtiUGbVkYBylBqUg=" crossorigin>
<link rel="stylesheet preload" as="style" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/styles/github.min.css" crossorigin>
<style>:root{--highlight-color:#fe9}.flex{display:flex !important}body{line-height:1.5em}#content{padding:20px}#sidebar{padding:30px;overflow:hidden}#sidebar > *:last-child{margin-bottom:2cm}.http-server-breadcrumbs{font-size:130%;margin:0 0 15px 0}#footer{font-size:.75em;padding:5px 30px;border-top:1px solid #ddd;text-align:right}#footer p{margin:0 0 0 1em;display:inline-block}#footer p:last-child{margin-right:30px}h1,h2,h3,h4,h5{font-weight:300}h1{font-size:2.5em;line-height:1.1em}h2{font-size:1.75em;margin:1em 0 .50em 0}h3{font-size:1.4em;margin:25px 0 10px 0}h4{margin:0;font-size:105%}h1:target,h2:target,h3:target,h4:target,h5:target,h6:target{background:var(--highlight-color);padding:.2em 0}a{color:#058;text-decoration:none;transition:color .3s ease-in-out}a:hover{color:#e82}.title code{font-weight:bold}h2[id^="header-"]{margin-top:2em}.ident{color:#900}pre code{background:#f8f8f8;font-size:.8em;line-height:1.4em}code{background:#f2f2f1;padding:1px 4px;overflow-wrap:break-word}h1 code{background:transparent}pre{background:#f8f8f8;border:0;border-top:1px solid #ccc;border-bottom:1px solid #ccc;margin:1em 0;padding:1ex}#http-server-module-list{display:flex;flex-flow:column}#http-server-module-list div{display:flex}#http-server-module-list dt{min-width:10%}#http-server-module-list p{margin-top:0}.toc ul,#index{list-style-type:none;margin:0;padding:0}#index code{background:transparent}#index h3{border-bottom:1px solid #ddd}#index ul{padding:0}#index h4{margin-top:.6em;font-weight:bold}@media (min-width:200ex){#index .two-column{column-count:2}}@media (min-width:300ex){#index .two-column{column-count:3}}dl{margin-bottom:2em}dl dl:last-child{margin-bottom:4em}dd{margin:0 0 1em 3em}#header-classes + dl > dd{margin-bottom:3em}dd dd{margin-left:2em}dd p{margin:10px 0}.name{background:#eee;font-weight:bold;font-size:.85em;padding:5px 10px;display:inline-block;min-width:40%}.name:hover{background:#e0e0e0}dt:target .name{background:var(--highlight-color)}.name > span:first-child{white-space:nowrap}.name.class > span:nth-child(2){margin-left:.4em}.inherited{color:#999;border-left:5px solid #eee;padding-left:1em}.inheritance em{font-style:normal;font-weight:bold}.desc h2{font-weight:400;font-size:1.25em}.desc h3{font-size:1em}.desc dt code{background:inherit}.source summary,.git-link-div{color:#666;text-align:right;font-weight:400;font-size:.8em;text-transform:uppercase}.source summary > *{white-space:nowrap;cursor:pointer}.git-link{color:inherit;margin-left:1em}.source pre{max-height:500px;overflow:auto;margin:0}.source pre code{font-size:12px;overflow:visible}.hlist{list-style:none}.hlist li{display:inline}.hlist li:after{content:',\2002'}.hlist li:last-child:after{content:none}.hlist .hlist{display:inline;padding-left:1em}img{max-width:100%}td{padding:0 .5em}.admonition{padding:.1em .5em;margin-bottom:1em}.admonition-title{font-weight:bold}.admonition.note,.admonition.info,.admonition.important{background:#aef}.admonition.todo,.admonition.versionadded,.admonition.tip,.admonition.hint{background:#dfd}.admonition.warning,.admonition.versionchanged,.admonition.deprecated{background:#fd4}.admonition.error,.admonition.danger,.admonition.caution{background:lightpink}</style>
<style media="screen and (min-width: 700px)">@media screen and (min-width:700px){#sidebar{width:30%;height:100vh;overflow:auto;position:sticky;top:0}#content{width:70%;max-width:100ch;padding:3em 4em;border-left:1px solid #ddd}pre code{font-size:1em}.item .name{font-size:1em}main{display:flex;flex-direction:row-reverse;justify-content:flex-end}.toc ul ul,#index ul{padding-left:1.5em}.toc > ul > li{margin-top:.5em}}</style>
<style media="print">@media print{#sidebar h1{page-break-before:always}.source{display:none}}@media print{*{background:transparent !important;color:#000 !important;box-shadow:none !important;text-shadow:none !important}a[href]:after{content:" (" attr(href) ")";font-size:90%}a[href][title]:after{content:none}abbr[title]:after{content:" (" attr(title) ")"}.ir a:after,a[href^="javascript:"]:after,a[href^="#"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100% !important}@page{margin:0.5cm}p,h2,h3{orphans:3;widows:3}h1,h2,h3,h4,h5,h6{page-break-after:avoid}}</style>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/highlight.min.js" integrity="sha256-Uv3H6lx7dJmRfRvH8TH6kJD1TSK1aFcwgx+mdg3epi8=" crossorigin></script>
<script>window.addEventListener('DOMContentLoaded', () => hljs.initHighlighting())</script>
</head>
<body>
<main>
<article id="content">
<header>
<h1 class="title">Module <code>meshtastic.node</code></h1>
</header>
<section id="section-intro">
<p>Node class</p>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">&#34;&#34;&#34;Node class
&#34;&#34;&#34;
import logging
import base64
from google.protobuf.json_format import MessageToJson
from . import portnums_pb2, apponly_pb2, admin_pb2, channel_pb2
from .util import pskToString, stripnl, Timeout, our_exit, fromPSK
class Node:
&#34;&#34;&#34;A model of a (local or remote) node in the mesh
Includes methods for radioConfig and channels
&#34;&#34;&#34;
def __init__(self, iface, nodeNum):
&#34;&#34;&#34;Constructor&#34;&#34;&#34;
self.iface = iface
self.nodeNum = nodeNum
self.radioConfig = None
self.channels = None
self._timeout = Timeout(maxSecs=60)
self.partialChannels = None
def showChannels(self):
&#34;&#34;&#34;Show human readable description of our channels.&#34;&#34;&#34;
print(&#34;Channels:&#34;)
if self.channels:
for c in self.channels:
cStr = stripnl(MessageToJson(c.settings))
# only show if there is no psk (meaning disabled channel)
if c.settings.psk:
print(f&#34; {channel_pb2.Channel.Role.Name(c.role)} psk={pskToString(c.settings.psk)} {cStr}&#34;)
publicURL = self.getURL(includeAll=False)
adminURL = self.getURL(includeAll=True)
print(f&#34;\nPrimary channel URL: {publicURL}&#34;)
if adminURL != publicURL:
print(f&#34;Complete URL (includes all channels): {adminURL}&#34;)
def showInfo(self):
&#34;&#34;&#34;Show human readable description of our node&#34;&#34;&#34;
prefs = &#34;&#34;
if self.radioConfig and self.radioConfig.preferences:
prefs = stripnl(MessageToJson(self.radioConfig.preferences))
print(f&#34;Preferences: {prefs}\n&#34;)
self.showChannels()
def requestConfig(self):
&#34;&#34;&#34;Send regular MeshPackets to ask for settings and channels.&#34;&#34;&#34;
self.radioConfig = None
self.channels = None
self.partialChannels = [] # We keep our channels in a temp array until finished
self._requestSettings()
def turnOffEncryptionOnPrimaryChannel(self):
&#34;&#34;&#34;Turn off encryption on primary channel.&#34;&#34;&#34;
self.channels[0].settings.psk = fromPSK(&#34;none&#34;)
print(&#34;Writing modified channels to device&#34;)
self.writeChannel(0)
def waitForConfig(self):
&#34;&#34;&#34;Block until radio config is received. Returns True if config has been received.&#34;&#34;&#34;
return self._timeout.waitForSet(self, attrs=(&#39;radioConfig&#39;, &#39;channels&#39;))
def writeConfig(self):
&#34;&#34;&#34;Write the current (edited) radioConfig to the device&#34;&#34;&#34;
if self.radioConfig is None:
our_exit(&#34;Error: No RadioConfig has been read&#34;)
p = admin_pb2.AdminMessage()
p.set_radio.CopyFrom(self.radioConfig)
self._sendAdmin(p)
logging.debug(&#34;Wrote config&#34;)
def writeChannel(self, channelIndex, adminIndex=0):
&#34;&#34;&#34;Write the current (edited) channel to the device&#34;&#34;&#34;
p = admin_pb2.AdminMessage()
p.set_channel.CopyFrom(self.channels[channelIndex])
self._sendAdmin(p, adminIndex=adminIndex)
logging.debug(f&#34;Wrote channel {channelIndex}&#34;)
def deleteChannel(self, channelIndex):
&#34;&#34;&#34;Delete the specifed channelIndex and shift other channels up&#34;&#34;&#34;
ch = self.channels[channelIndex]
if ch.role not in (channel_pb2.Channel.Role.SECONDARY, channel_pb2.Channel.Role.DISABLED):
our_exit(&#34;Warning: Only SECONDARY channels can be deleted&#34;)
# we are careful here because if we move the &#34;admin&#34; channel the channelIndex we need to use
# for sending admin channels will also change
adminIndex = self.iface.localNode._getAdminChannelIndex()
self.channels.pop(channelIndex)
self._fixupChannels() # expand back to 8 channels
index = channelIndex
while index &lt; self.iface.myInfo.max_channels:
self.writeChannel(index, adminIndex=adminIndex)
index += 1
# if we are updating the local node, we might end up
# *moving* the admin channel index as we are writing
if (self.iface.localNode == self) and index &gt;= adminIndex:
# We&#39;ve now passed the old location for admin index
# (and writen it), so we can start finding it by name again
adminIndex = 0
def getChannelByName(self, name):
&#34;&#34;&#34;Try to find the named channel or return None&#34;&#34;&#34;
for c in (self.channels or []):
if c.settings and c.settings.name == name:
return c
return None
def getDisabledChannel(self):
&#34;&#34;&#34;Return the first channel that is disabled (i.e. available for some new use)&#34;&#34;&#34;
for c in self.channels:
if c.role == channel_pb2.Channel.Role.DISABLED:
return c
return None
def _getAdminChannelIndex(self):
&#34;&#34;&#34;Return the channel number of the admin channel, or 0 if no reserved channel&#34;&#34;&#34;
c = self.getChannelByName(&#34;admin&#34;)
if c:
return c.index
else:
return 0
def setOwner(self, long_name=None, short_name=None, is_licensed=False, team=None):
&#34;&#34;&#34;Set device owner name&#34;&#34;&#34;
nChars = 3
minChars = 2
if long_name is not None:
long_name = long_name.strip()
if short_name is None:
words = long_name.split()
if len(long_name) &lt;= nChars:
short_name = long_name
elif len(words) &gt;= minChars:
short_name = &#39;&#39;.join(map(lambda word: word[0], words))
else:
trans = str.maketrans(dict.fromkeys(&#39;aeiouAEIOU&#39;))
short_name = long_name[0] + long_name[1:].translate(trans)
if len(short_name) &lt; nChars:
short_name = long_name[:nChars]
p = admin_pb2.AdminMessage()
if long_name is not None:
p.set_owner.long_name = long_name
if short_name is not None:
short_name = short_name.strip()
if len(short_name) &gt; nChars:
short_name = short_name[:nChars]
p.set_owner.short_name = short_name
p.set_owner.is_licensed = is_licensed
if team is not None:
p.set_owner.team = team
return self._sendAdmin(p)
def getURL(self, includeAll: bool = True):
&#34;&#34;&#34;The sharable URL that describes the current channel&#34;&#34;&#34;
# Only keep the primary/secondary channels, assume primary is first
channelSet = apponly_pb2.ChannelSet()
if self.channels:
for c in self.channels:
if c.role == channel_pb2.Channel.Role.PRIMARY or (includeAll and c.role == channel_pb2.Channel.Role.SECONDARY):
channelSet.settings.append(c.settings)
some_bytes = channelSet.SerializeToString()
s = base64.urlsafe_b64encode(some_bytes).decode(&#39;ascii&#39;)
return f&#34;https://www.meshtastic.org/d/#{s}&#34;.replace(&#34;=&#34;, &#34;&#34;)
def setURL(self, url):
&#34;&#34;&#34;Set mesh network URL&#34;&#34;&#34;
if self.radioConfig is None:
our_exit(&#34;Warning: No RadioConfig has been read&#34;)
# URLs are of the form https://www.meshtastic.org/d/#{base64_channel_set}
# Split on &#39;/#&#39; to find the base64 encoded channel settings
splitURL = url.split(&#34;/#&#34;)
b64 = splitURL[-1]
# We normally strip padding to make for a shorter URL, but the python parser doesn&#39;t like
# that. So add back any missing padding
# per https://stackoverflow.com/a/9807138
missing_padding = len(b64) % 4
if missing_padding:
b64 += &#39;=&#39; * (4 - missing_padding)
decodedURL = base64.urlsafe_b64decode(b64)
channelSet = apponly_pb2.ChannelSet()
channelSet.ParseFromString(decodedURL)
if len(channelSet.settings) == 0:
our_exit(&#34;Warning: There were no settings.&#34;)
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
self.writeChannel(ch.index)
i = i + 1
def _requestSettings(self):
&#34;&#34;&#34;Done with initial config messages, now send regular
MeshPackets to ask for settings.&#34;&#34;&#34;
p = admin_pb2.AdminMessage()
p.get_radio_request = True
def onResponse(p):
&#34;&#34;&#34;A closure to handle the response packet&#34;&#34;&#34;
errorFound = False
if &#39;routing&#39; in p[&#34;decoded&#34;]:
if p[&#34;decoded&#34;][&#34;routing&#34;][&#34;errorReason&#34;] != &#34;NONE&#34;:
errorFound = True
print(f&#39;Error on response: {p[&#34;decoded&#34;][&#34;routing&#34;][&#34;errorReason&#34;]}&#39;)
if errorFound is False:
self.radioConfig = p[&#34;decoded&#34;][&#34;admin&#34;][&#34;raw&#34;].get_radio_response
logging.debug(&#34;Received radio config, now fetching channels...&#34;)
self._timeout.reset() # We made foreward progress
self._requestChannel(0) # now start fetching channels
# Show progress message for super slow operations
if self != self.iface.localNode:
print(&#34;Requesting preferences from remote node (this could take a while)&#34;)
return self._sendAdmin(p, wantResponse=True, onResponse=onResponse)
def exitSimulator(self):
&#34;&#34;&#34;Tell a simulator node to exit (this message
is ignored for other nodes)&#34;&#34;&#34;
p = admin_pb2.AdminMessage()
p.exit_simulator = True
return self._sendAdmin(p)
def reboot(self, secs: int = 10):
&#34;&#34;&#34;Tell the node to reboot.&#34;&#34;&#34;
p = admin_pb2.AdminMessage()
p.reboot_seconds = secs
logging.info(f&#34;Telling node to reboot in {secs} seconds&#34;)
return self._sendAdmin(p)
def _fixupChannels(self):
&#34;&#34;&#34;Fixup indexes and add disabled channels as needed&#34;&#34;&#34;
# Add extra disabled channels as needed
# TODO: These 2 lines seem to not do anything.
for index, ch in enumerate(self.channels):
ch.index = index # fixup indexes
self._fillChannels()
def _fillChannels(self):
&#34;&#34;&#34;Mark unused channels as disabled&#34;&#34;&#34;
# Add extra disabled channels as needed
index = len(self.channels)
while index &lt; self.iface.myInfo.max_channels:
ch = channel_pb2.Channel()
ch.role = channel_pb2.Channel.Role.DISABLED
ch.index = index
self.channels.append(ch)
index += 1
def _requestChannel(self, channelNum: int):
&#34;&#34;&#34;Done with initial config messages, now send regular
MeshPackets to ask for settings&#34;&#34;&#34;
p = admin_pb2.AdminMessage()
p.get_channel_request = channelNum + 1
# Show progress message for super slow operations
if self != self.iface.localNode:
logging.info(f&#34;Requesting channel {channelNum} info from remote node (this could take a while)&#34;)
else:
logging.debug(f&#34;Requesting channel {channelNum}&#34;)
def onResponse(p):
&#34;&#34;&#34;A closure to handle the response packet for requesting a channel&#34;&#34;&#34;
c = p[&#34;decoded&#34;][&#34;admin&#34;][&#34;raw&#34;].get_channel_response
self.partialChannels.append(c)
self._timeout.reset() # We made foreward progress
logging.debug(f&#34;Received channel {stripnl(c)}&#34;)
index = c.index
# for stress testing, we can always download all channels
fastChannelDownload = True
# Once we see a response that has NO settings, assume
# we are at the end of channels and stop fetching
quitEarly = (c.role == channel_pb2.Channel.Role.DISABLED) and fastChannelDownload
if quitEarly or index &gt;= self.iface.myInfo.max_channels - 1:
logging.debug(&#34;Finished downloading channels&#34;)
self.channels = self.partialChannels
self._fixupChannels()
# FIXME, the following should only be called after we have settings and channels
self.iface._connected() # Tell everone else we are ready to go
else:
self._requestChannel(index + 1)
return self._sendAdmin(p, wantResponse=True, onResponse=onResponse)
def _sendAdmin(self, p: admin_pb2.AdminMessage, wantResponse=False,
onResponse=None, adminIndex=0):
&#34;&#34;&#34;Send an admin message to the specified node (or the local node if destNodeNum is zero)&#34;&#34;&#34;
if adminIndex == 0: # unless a special channel index was used, we want to use the admin index
adminIndex = self.iface.localNode._getAdminChannelIndex()
return self.iface.sendData(p, self.nodeNum,
portNum=portnums_pb2.PortNum.ADMIN_APP,
wantAck=True,
wantResponse=wantResponse,
onResponse=onResponse,
channelIndex=adminIndex)</code></pre>
</details>
</section>
<section>
</section>
<section>
</section>
<section>
</section>
<section>
<h2 class="section-title" id="header-classes">Classes</h2>
<dl>
<dt id="meshtastic.node.Node"><code class="flex name class">
<span>class <span class="ident">Node</span></span>
<span>(</span><span>iface, nodeNum)</span>
</code></dt>
<dd>
<div class="desc"><p>A model of a (local or remote) node in the mesh</p>
<p>Includes methods for radioConfig and channels</p>
<p>Constructor</p></div>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class Node:
&#34;&#34;&#34;A model of a (local or remote) node in the mesh
Includes methods for radioConfig and channels
&#34;&#34;&#34;
def __init__(self, iface, nodeNum):
&#34;&#34;&#34;Constructor&#34;&#34;&#34;
self.iface = iface
self.nodeNum = nodeNum
self.radioConfig = None
self.channels = None
self._timeout = Timeout(maxSecs=60)
self.partialChannels = None
def showChannels(self):
&#34;&#34;&#34;Show human readable description of our channels.&#34;&#34;&#34;
print(&#34;Channels:&#34;)
if self.channels:
for c in self.channels:
cStr = stripnl(MessageToJson(c.settings))
# only show if there is no psk (meaning disabled channel)
if c.settings.psk:
print(f&#34; {channel_pb2.Channel.Role.Name(c.role)} psk={pskToString(c.settings.psk)} {cStr}&#34;)
publicURL = self.getURL(includeAll=False)
adminURL = self.getURL(includeAll=True)
print(f&#34;\nPrimary channel URL: {publicURL}&#34;)
if adminURL != publicURL:
print(f&#34;Complete URL (includes all channels): {adminURL}&#34;)
def showInfo(self):
&#34;&#34;&#34;Show human readable description of our node&#34;&#34;&#34;
prefs = &#34;&#34;
if self.radioConfig and self.radioConfig.preferences:
prefs = stripnl(MessageToJson(self.radioConfig.preferences))
print(f&#34;Preferences: {prefs}\n&#34;)
self.showChannels()
def requestConfig(self):
&#34;&#34;&#34;Send regular MeshPackets to ask for settings and channels.&#34;&#34;&#34;
self.radioConfig = None
self.channels = None
self.partialChannels = [] # We keep our channels in a temp array until finished
self._requestSettings()
def turnOffEncryptionOnPrimaryChannel(self):
&#34;&#34;&#34;Turn off encryption on primary channel.&#34;&#34;&#34;
self.channels[0].settings.psk = fromPSK(&#34;none&#34;)
print(&#34;Writing modified channels to device&#34;)
self.writeChannel(0)
def waitForConfig(self):
&#34;&#34;&#34;Block until radio config is received. Returns True if config has been received.&#34;&#34;&#34;
return self._timeout.waitForSet(self, attrs=(&#39;radioConfig&#39;, &#39;channels&#39;))
def writeConfig(self):
&#34;&#34;&#34;Write the current (edited) radioConfig to the device&#34;&#34;&#34;
if self.radioConfig is None:
our_exit(&#34;Error: No RadioConfig has been read&#34;)
p = admin_pb2.AdminMessage()
p.set_radio.CopyFrom(self.radioConfig)
self._sendAdmin(p)
logging.debug(&#34;Wrote config&#34;)
def writeChannel(self, channelIndex, adminIndex=0):
&#34;&#34;&#34;Write the current (edited) channel to the device&#34;&#34;&#34;
p = admin_pb2.AdminMessage()
p.set_channel.CopyFrom(self.channels[channelIndex])
self._sendAdmin(p, adminIndex=adminIndex)
logging.debug(f&#34;Wrote channel {channelIndex}&#34;)
def deleteChannel(self, channelIndex):
&#34;&#34;&#34;Delete the specifed channelIndex and shift other channels up&#34;&#34;&#34;
ch = self.channels[channelIndex]
if ch.role not in (channel_pb2.Channel.Role.SECONDARY, channel_pb2.Channel.Role.DISABLED):
our_exit(&#34;Warning: Only SECONDARY channels can be deleted&#34;)
# we are careful here because if we move the &#34;admin&#34; channel the channelIndex we need to use
# for sending admin channels will also change
adminIndex = self.iface.localNode._getAdminChannelIndex()
self.channels.pop(channelIndex)
self._fixupChannels() # expand back to 8 channels
index = channelIndex
while index &lt; self.iface.myInfo.max_channels:
self.writeChannel(index, adminIndex=adminIndex)
index += 1
# if we are updating the local node, we might end up
# *moving* the admin channel index as we are writing
if (self.iface.localNode == self) and index &gt;= adminIndex:
# We&#39;ve now passed the old location for admin index
# (and writen it), so we can start finding it by name again
adminIndex = 0
def getChannelByName(self, name):
&#34;&#34;&#34;Try to find the named channel or return None&#34;&#34;&#34;
for c in (self.channels or []):
if c.settings and c.settings.name == name:
return c
return None
def getDisabledChannel(self):
&#34;&#34;&#34;Return the first channel that is disabled (i.e. available for some new use)&#34;&#34;&#34;
for c in self.channels:
if c.role == channel_pb2.Channel.Role.DISABLED:
return c
return None
def _getAdminChannelIndex(self):
&#34;&#34;&#34;Return the channel number of the admin channel, or 0 if no reserved channel&#34;&#34;&#34;
c = self.getChannelByName(&#34;admin&#34;)
if c:
return c.index
else:
return 0
def setOwner(self, long_name=None, short_name=None, is_licensed=False, team=None):
&#34;&#34;&#34;Set device owner name&#34;&#34;&#34;
nChars = 3
minChars = 2
if long_name is not None:
long_name = long_name.strip()
if short_name is None:
words = long_name.split()
if len(long_name) &lt;= nChars:
short_name = long_name
elif len(words) &gt;= minChars:
short_name = &#39;&#39;.join(map(lambda word: word[0], words))
else:
trans = str.maketrans(dict.fromkeys(&#39;aeiouAEIOU&#39;))
short_name = long_name[0] + long_name[1:].translate(trans)
if len(short_name) &lt; nChars:
short_name = long_name[:nChars]
p = admin_pb2.AdminMessage()
if long_name is not None:
p.set_owner.long_name = long_name
if short_name is not None:
short_name = short_name.strip()
if len(short_name) &gt; nChars:
short_name = short_name[:nChars]
p.set_owner.short_name = short_name
p.set_owner.is_licensed = is_licensed
if team is not None:
p.set_owner.team = team
return self._sendAdmin(p)
def getURL(self, includeAll: bool = True):
&#34;&#34;&#34;The sharable URL that describes the current channel&#34;&#34;&#34;
# Only keep the primary/secondary channels, assume primary is first
channelSet = apponly_pb2.ChannelSet()
if self.channels:
for c in self.channels:
if c.role == channel_pb2.Channel.Role.PRIMARY or (includeAll and c.role == channel_pb2.Channel.Role.SECONDARY):
channelSet.settings.append(c.settings)
some_bytes = channelSet.SerializeToString()
s = base64.urlsafe_b64encode(some_bytes).decode(&#39;ascii&#39;)
return f&#34;https://www.meshtastic.org/d/#{s}&#34;.replace(&#34;=&#34;, &#34;&#34;)
def setURL(self, url):
&#34;&#34;&#34;Set mesh network URL&#34;&#34;&#34;
if self.radioConfig is None:
our_exit(&#34;Warning: No RadioConfig has been read&#34;)
# URLs are of the form https://www.meshtastic.org/d/#{base64_channel_set}
# Split on &#39;/#&#39; to find the base64 encoded channel settings
splitURL = url.split(&#34;/#&#34;)
b64 = splitURL[-1]
# We normally strip padding to make for a shorter URL, but the python parser doesn&#39;t like
# that. So add back any missing padding
# per https://stackoverflow.com/a/9807138
missing_padding = len(b64) % 4
if missing_padding:
b64 += &#39;=&#39; * (4 - missing_padding)
decodedURL = base64.urlsafe_b64decode(b64)
channelSet = apponly_pb2.ChannelSet()
channelSet.ParseFromString(decodedURL)
if len(channelSet.settings) == 0:
our_exit(&#34;Warning: There were no settings.&#34;)
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
self.writeChannel(ch.index)
i = i + 1
def _requestSettings(self):
&#34;&#34;&#34;Done with initial config messages, now send regular
MeshPackets to ask for settings.&#34;&#34;&#34;
p = admin_pb2.AdminMessage()
p.get_radio_request = True
def onResponse(p):
&#34;&#34;&#34;A closure to handle the response packet&#34;&#34;&#34;
errorFound = False
if &#39;routing&#39; in p[&#34;decoded&#34;]:
if p[&#34;decoded&#34;][&#34;routing&#34;][&#34;errorReason&#34;] != &#34;NONE&#34;:
errorFound = True
print(f&#39;Error on response: {p[&#34;decoded&#34;][&#34;routing&#34;][&#34;errorReason&#34;]}&#39;)
if errorFound is False:
self.radioConfig = p[&#34;decoded&#34;][&#34;admin&#34;][&#34;raw&#34;].get_radio_response
logging.debug(&#34;Received radio config, now fetching channels...&#34;)
self._timeout.reset() # We made foreward progress
self._requestChannel(0) # now start fetching channels
# Show progress message for super slow operations
if self != self.iface.localNode:
print(&#34;Requesting preferences from remote node (this could take a while)&#34;)
return self._sendAdmin(p, wantResponse=True, onResponse=onResponse)
def exitSimulator(self):
&#34;&#34;&#34;Tell a simulator node to exit (this message
is ignored for other nodes)&#34;&#34;&#34;
p = admin_pb2.AdminMessage()
p.exit_simulator = True
return self._sendAdmin(p)
def reboot(self, secs: int = 10):
&#34;&#34;&#34;Tell the node to reboot.&#34;&#34;&#34;
p = admin_pb2.AdminMessage()
p.reboot_seconds = secs
logging.info(f&#34;Telling node to reboot in {secs} seconds&#34;)
return self._sendAdmin(p)
def _fixupChannels(self):
&#34;&#34;&#34;Fixup indexes and add disabled channels as needed&#34;&#34;&#34;
# Add extra disabled channels as needed
# TODO: These 2 lines seem to not do anything.
for index, ch in enumerate(self.channels):
ch.index = index # fixup indexes
self._fillChannels()
def _fillChannels(self):
&#34;&#34;&#34;Mark unused channels as disabled&#34;&#34;&#34;
# Add extra disabled channels as needed
index = len(self.channels)
while index &lt; self.iface.myInfo.max_channels:
ch = channel_pb2.Channel()
ch.role = channel_pb2.Channel.Role.DISABLED
ch.index = index
self.channels.append(ch)
index += 1
def _requestChannel(self, channelNum: int):
&#34;&#34;&#34;Done with initial config messages, now send regular
MeshPackets to ask for settings&#34;&#34;&#34;
p = admin_pb2.AdminMessage()
p.get_channel_request = channelNum + 1
# Show progress message for super slow operations
if self != self.iface.localNode:
logging.info(f&#34;Requesting channel {channelNum} info from remote node (this could take a while)&#34;)
else:
logging.debug(f&#34;Requesting channel {channelNum}&#34;)
def onResponse(p):
&#34;&#34;&#34;A closure to handle the response packet for requesting a channel&#34;&#34;&#34;
c = p[&#34;decoded&#34;][&#34;admin&#34;][&#34;raw&#34;].get_channel_response
self.partialChannels.append(c)
self._timeout.reset() # We made foreward progress
logging.debug(f&#34;Received channel {stripnl(c)}&#34;)
index = c.index
# for stress testing, we can always download all channels
fastChannelDownload = True
# Once we see a response that has NO settings, assume
# we are at the end of channels and stop fetching
quitEarly = (c.role == channel_pb2.Channel.Role.DISABLED) and fastChannelDownload
if quitEarly or index &gt;= self.iface.myInfo.max_channels - 1:
logging.debug(&#34;Finished downloading channels&#34;)
self.channels = self.partialChannels
self._fixupChannels()
# FIXME, the following should only be called after we have settings and channels
self.iface._connected() # Tell everone else we are ready to go
else:
self._requestChannel(index + 1)
return self._sendAdmin(p, wantResponse=True, onResponse=onResponse)
def _sendAdmin(self, p: admin_pb2.AdminMessage, wantResponse=False,
onResponse=None, adminIndex=0):
&#34;&#34;&#34;Send an admin message to the specified node (or the local node if destNodeNum is zero)&#34;&#34;&#34;
if adminIndex == 0: # unless a special channel index was used, we want to use the admin index
adminIndex = self.iface.localNode._getAdminChannelIndex()
return self.iface.sendData(p, self.nodeNum,
portNum=portnums_pb2.PortNum.ADMIN_APP,
wantAck=True,
wantResponse=wantResponse,
onResponse=onResponse,
channelIndex=adminIndex)</code></pre>
</details>
<h3>Methods</h3>
<dl>
<dt id="meshtastic.node.Node.deleteChannel"><code class="name flex">
<span>def <span class="ident">deleteChannel</span></span>(<span>self, channelIndex)</span>
</code></dt>
<dd>
<div class="desc"><p>Delete the specifed channelIndex and shift other channels up</p></div>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def deleteChannel(self, channelIndex):
&#34;&#34;&#34;Delete the specifed channelIndex and shift other channels up&#34;&#34;&#34;
ch = self.channels[channelIndex]
if ch.role not in (channel_pb2.Channel.Role.SECONDARY, channel_pb2.Channel.Role.DISABLED):
our_exit(&#34;Warning: Only SECONDARY channels can be deleted&#34;)
# we are careful here because if we move the &#34;admin&#34; channel the channelIndex we need to use
# for sending admin channels will also change
adminIndex = self.iface.localNode._getAdminChannelIndex()
self.channels.pop(channelIndex)
self._fixupChannels() # expand back to 8 channels
index = channelIndex
while index &lt; self.iface.myInfo.max_channels:
self.writeChannel(index, adminIndex=adminIndex)
index += 1
# if we are updating the local node, we might end up
# *moving* the admin channel index as we are writing
if (self.iface.localNode == self) and index &gt;= adminIndex:
# We&#39;ve now passed the old location for admin index
# (and writen it), so we can start finding it by name again
adminIndex = 0</code></pre>
</details>
</dd>
<dt id="meshtastic.node.Node.exitSimulator"><code class="name flex">
<span>def <span class="ident">exitSimulator</span></span>(<span>self)</span>
</code></dt>
<dd>
<div class="desc"><p>Tell a simulator node to exit (this message
is ignored for other nodes)</p></div>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def exitSimulator(self):
&#34;&#34;&#34;Tell a simulator node to exit (this message
is ignored for other nodes)&#34;&#34;&#34;
p = admin_pb2.AdminMessage()
p.exit_simulator = True
return self._sendAdmin(p)</code></pre>
</details>
</dd>
<dt id="meshtastic.node.Node.getChannelByName"><code class="name flex">
<span>def <span class="ident">getChannelByName</span></span>(<span>self, name)</span>
</code></dt>
<dd>
<div class="desc"><p>Try to find the named channel or return None</p></div>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def getChannelByName(self, name):
&#34;&#34;&#34;Try to find the named channel or return None&#34;&#34;&#34;
for c in (self.channels or []):
if c.settings and c.settings.name == name:
return c
return None</code></pre>
</details>
</dd>
<dt id="meshtastic.node.Node.getDisabledChannel"><code class="name flex">
<span>def <span class="ident">getDisabledChannel</span></span>(<span>self)</span>
</code></dt>
<dd>
<div class="desc"><p>Return the first channel that is disabled (i.e. available for some new use)</p></div>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def getDisabledChannel(self):
&#34;&#34;&#34;Return the first channel that is disabled (i.e. available for some new use)&#34;&#34;&#34;
for c in self.channels:
if c.role == channel_pb2.Channel.Role.DISABLED:
return c
return None</code></pre>
</details>
</dd>
<dt id="meshtastic.node.Node.getURL"><code class="name flex">
<span>def <span class="ident">getURL</span></span>(<span>self, includeAll: bool = True)</span>
</code></dt>
<dd>
<div class="desc"><p>The sharable URL that describes the current channel</p></div>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def getURL(self, includeAll: bool = True):
&#34;&#34;&#34;The sharable URL that describes the current channel&#34;&#34;&#34;
# Only keep the primary/secondary channels, assume primary is first
channelSet = apponly_pb2.ChannelSet()
if self.channels:
for c in self.channels:
if c.role == channel_pb2.Channel.Role.PRIMARY or (includeAll and c.role == channel_pb2.Channel.Role.SECONDARY):
channelSet.settings.append(c.settings)
some_bytes = channelSet.SerializeToString()
s = base64.urlsafe_b64encode(some_bytes).decode(&#39;ascii&#39;)
return f&#34;https://www.meshtastic.org/d/#{s}&#34;.replace(&#34;=&#34;, &#34;&#34;)</code></pre>
</details>
</dd>
<dt id="meshtastic.node.Node.reboot"><code class="name flex">
<span>def <span class="ident">reboot</span></span>(<span>self, secs: int = 10)</span>
</code></dt>
<dd>
<div class="desc"><p>Tell the node to reboot.</p></div>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def reboot(self, secs: int = 10):
&#34;&#34;&#34;Tell the node to reboot.&#34;&#34;&#34;
p = admin_pb2.AdminMessage()
p.reboot_seconds = secs
logging.info(f&#34;Telling node to reboot in {secs} seconds&#34;)
return self._sendAdmin(p)</code></pre>
</details>
</dd>
<dt id="meshtastic.node.Node.requestConfig"><code class="name flex">
<span>def <span class="ident">requestConfig</span></span>(<span>self)</span>
</code></dt>
<dd>
<div class="desc"><p>Send regular MeshPackets to ask for settings and channels.</p></div>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def requestConfig(self):
&#34;&#34;&#34;Send regular MeshPackets to ask for settings and channels.&#34;&#34;&#34;
self.radioConfig = None
self.channels = None
self.partialChannels = [] # We keep our channels in a temp array until finished
self._requestSettings()</code></pre>
</details>
</dd>
<dt id="meshtastic.node.Node.setOwner"><code class="name flex">
<span>def <span class="ident">setOwner</span></span>(<span>self, long_name=None, short_name=None, is_licensed=False, team=None)</span>
</code></dt>
<dd>
<div class="desc"><p>Set device owner name</p></div>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def setOwner(self, long_name=None, short_name=None, is_licensed=False, team=None):
&#34;&#34;&#34;Set device owner name&#34;&#34;&#34;
nChars = 3
minChars = 2
if long_name is not None:
long_name = long_name.strip()
if short_name is None:
words = long_name.split()
if len(long_name) &lt;= nChars:
short_name = long_name
elif len(words) &gt;= minChars:
short_name = &#39;&#39;.join(map(lambda word: word[0], words))
else:
trans = str.maketrans(dict.fromkeys(&#39;aeiouAEIOU&#39;))
short_name = long_name[0] + long_name[1:].translate(trans)
if len(short_name) &lt; nChars:
short_name = long_name[:nChars]
p = admin_pb2.AdminMessage()
if long_name is not None:
p.set_owner.long_name = long_name
if short_name is not None:
short_name = short_name.strip()
if len(short_name) &gt; nChars:
short_name = short_name[:nChars]
p.set_owner.short_name = short_name
p.set_owner.is_licensed = is_licensed
if team is not None:
p.set_owner.team = team
return self._sendAdmin(p)</code></pre>
</details>
</dd>
<dt id="meshtastic.node.Node.setURL"><code class="name flex">
<span>def <span class="ident">setURL</span></span>(<span>self, url)</span>
</code></dt>
<dd>
<div class="desc"><p>Set mesh network URL</p></div>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def setURL(self, url):
&#34;&#34;&#34;Set mesh network URL&#34;&#34;&#34;
if self.radioConfig is None:
our_exit(&#34;Warning: No RadioConfig has been read&#34;)
# URLs are of the form https://www.meshtastic.org/d/#{base64_channel_set}
# Split on &#39;/#&#39; to find the base64 encoded channel settings
splitURL = url.split(&#34;/#&#34;)
b64 = splitURL[-1]
# We normally strip padding to make for a shorter URL, but the python parser doesn&#39;t like
# that. So add back any missing padding
# per https://stackoverflow.com/a/9807138
missing_padding = len(b64) % 4
if missing_padding:
b64 += &#39;=&#39; * (4 - missing_padding)
decodedURL = base64.urlsafe_b64decode(b64)
channelSet = apponly_pb2.ChannelSet()
channelSet.ParseFromString(decodedURL)
if len(channelSet.settings) == 0:
our_exit(&#34;Warning: There were no settings.&#34;)
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
self.writeChannel(ch.index)
i = i + 1</code></pre>
</details>
</dd>
<dt id="meshtastic.node.Node.showChannels"><code class="name flex">
<span>def <span class="ident">showChannels</span></span>(<span>self)</span>
</code></dt>
<dd>
<div class="desc"><p>Show human readable description of our channels.</p></div>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def showChannels(self):
&#34;&#34;&#34;Show human readable description of our channels.&#34;&#34;&#34;
print(&#34;Channels:&#34;)
if self.channels:
for c in self.channels:
cStr = stripnl(MessageToJson(c.settings))
# only show if there is no psk (meaning disabled channel)
if c.settings.psk:
print(f&#34; {channel_pb2.Channel.Role.Name(c.role)} psk={pskToString(c.settings.psk)} {cStr}&#34;)
publicURL = self.getURL(includeAll=False)
adminURL = self.getURL(includeAll=True)
print(f&#34;\nPrimary channel URL: {publicURL}&#34;)
if adminURL != publicURL:
print(f&#34;Complete URL (includes all channels): {adminURL}&#34;)</code></pre>
</details>
</dd>
<dt id="meshtastic.node.Node.showInfo"><code class="name flex">
<span>def <span class="ident">showInfo</span></span>(<span>self)</span>
</code></dt>
<dd>
<div class="desc"><p>Show human readable description of our node</p></div>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def showInfo(self):
&#34;&#34;&#34;Show human readable description of our node&#34;&#34;&#34;
prefs = &#34;&#34;
if self.radioConfig and self.radioConfig.preferences:
prefs = stripnl(MessageToJson(self.radioConfig.preferences))
print(f&#34;Preferences: {prefs}\n&#34;)
self.showChannels()</code></pre>
</details>
</dd>
<dt id="meshtastic.node.Node.turnOffEncryptionOnPrimaryChannel"><code class="name flex">
<span>def <span class="ident">turnOffEncryptionOnPrimaryChannel</span></span>(<span>self)</span>
</code></dt>
<dd>
<div class="desc"><p>Turn off encryption on primary channel.</p></div>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def turnOffEncryptionOnPrimaryChannel(self):
&#34;&#34;&#34;Turn off encryption on primary channel.&#34;&#34;&#34;
self.channels[0].settings.psk = fromPSK(&#34;none&#34;)
print(&#34;Writing modified channels to device&#34;)
self.writeChannel(0)</code></pre>
</details>
</dd>
<dt id="meshtastic.node.Node.waitForConfig"><code class="name flex">
<span>def <span class="ident">waitForConfig</span></span>(<span>self)</span>
</code></dt>
<dd>
<div class="desc"><p>Block until radio config is received. Returns True if config has been received.</p></div>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def waitForConfig(self):
&#34;&#34;&#34;Block until radio config is received. Returns True if config has been received.&#34;&#34;&#34;
return self._timeout.waitForSet(self, attrs=(&#39;radioConfig&#39;, &#39;channels&#39;))</code></pre>
</details>
</dd>
<dt id="meshtastic.node.Node.writeChannel"><code class="name flex">
<span>def <span class="ident">writeChannel</span></span>(<span>self, channelIndex, adminIndex=0)</span>
</code></dt>
<dd>
<div class="desc"><p>Write the current (edited) channel to the device</p></div>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def writeChannel(self, channelIndex, adminIndex=0):
&#34;&#34;&#34;Write the current (edited) channel to the device&#34;&#34;&#34;
p = admin_pb2.AdminMessage()
p.set_channel.CopyFrom(self.channels[channelIndex])
self._sendAdmin(p, adminIndex=adminIndex)
logging.debug(f&#34;Wrote channel {channelIndex}&#34;)</code></pre>
</details>
</dd>
<dt id="meshtastic.node.Node.writeConfig"><code class="name flex">
<span>def <span class="ident">writeConfig</span></span>(<span>self)</span>
</code></dt>
<dd>
<div class="desc"><p>Write the current (edited) radioConfig to the device</p></div>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def writeConfig(self):
&#34;&#34;&#34;Write the current (edited) radioConfig to the device&#34;&#34;&#34;
if self.radioConfig is None:
our_exit(&#34;Error: No RadioConfig has been read&#34;)
p = admin_pb2.AdminMessage()
p.set_radio.CopyFrom(self.radioConfig)
self._sendAdmin(p)
logging.debug(&#34;Wrote config&#34;)</code></pre>
</details>
</dd>
</dl>
</dd>
</dl>
</section>
</article>
<nav id="sidebar">
<h1>Index</h1>
<div class="toc">
<ul></ul>
</div>
<ul id="index">
<li><h3>Super-module</h3>
<ul>
<li><code><a title="meshtastic" href="index.html">meshtastic</a></code></li>
</ul>
</li>
<li><h3><a href="#header-classes">Classes</a></h3>
<ul>
<li>
<h4><code><a title="meshtastic.node.Node" href="#meshtastic.node.Node">Node</a></code></h4>
<ul class="">
<li><code><a title="meshtastic.node.Node.deleteChannel" href="#meshtastic.node.Node.deleteChannel">deleteChannel</a></code></li>
<li><code><a title="meshtastic.node.Node.exitSimulator" href="#meshtastic.node.Node.exitSimulator">exitSimulator</a></code></li>
<li><code><a title="meshtastic.node.Node.getChannelByName" href="#meshtastic.node.Node.getChannelByName">getChannelByName</a></code></li>
<li><code><a title="meshtastic.node.Node.getDisabledChannel" href="#meshtastic.node.Node.getDisabledChannel">getDisabledChannel</a></code></li>
<li><code><a title="meshtastic.node.Node.getURL" href="#meshtastic.node.Node.getURL">getURL</a></code></li>
<li><code><a title="meshtastic.node.Node.reboot" href="#meshtastic.node.Node.reboot">reboot</a></code></li>
<li><code><a title="meshtastic.node.Node.requestConfig" href="#meshtastic.node.Node.requestConfig">requestConfig</a></code></li>
<li><code><a title="meshtastic.node.Node.setOwner" href="#meshtastic.node.Node.setOwner">setOwner</a></code></li>
<li><code><a title="meshtastic.node.Node.setURL" href="#meshtastic.node.Node.setURL">setURL</a></code></li>
<li><code><a title="meshtastic.node.Node.showChannels" href="#meshtastic.node.Node.showChannels">showChannels</a></code></li>
<li><code><a title="meshtastic.node.Node.showInfo" href="#meshtastic.node.Node.showInfo">showInfo</a></code></li>
<li><code><a title="meshtastic.node.Node.turnOffEncryptionOnPrimaryChannel" href="#meshtastic.node.Node.turnOffEncryptionOnPrimaryChannel">turnOffEncryptionOnPrimaryChannel</a></code></li>
<li><code><a title="meshtastic.node.Node.waitForConfig" href="#meshtastic.node.Node.waitForConfig">waitForConfig</a></code></li>
<li><code><a title="meshtastic.node.Node.writeChannel" href="#meshtastic.node.Node.writeChannel">writeChannel</a></code></li>
<li><code><a title="meshtastic.node.Node.writeConfig" href="#meshtastic.node.Node.writeConfig">writeConfig</a></code></li>
</ul>
</li>
</ul>
</li>
</ul>
</nav>
</main>
<footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.10.0</a>.</p>
</footer>
</body>
</html>