mirror of
https://github.com/meshtastic/python.git
synced 2025-12-27 01:47:50 -05:00
1.2.11
This commit is contained in:
@@ -51,7 +51,7 @@ DESCRIPTOR = _descriptor.FileDescriptor(
|
||||
syntax='proto3',
|
||||
serialized_options=b'\n\023com.geeksville.meshB\013AdminProtosH\003',
|
||||
create_key=_descriptor._internal_create_key,
|
||||
serialized_pb=b'\n\x0b\x61\x64min.proto\x1a\nmesh.proto\x1a\x11radioconfig.proto\x1a\rchannel.proto\"\xc7\x02\n\x0c\x41\x64minMessage\x12!\n\tset_radio\x18\x01 \x01(\x0b\x32\x0c.RadioConfigH\x00\x12\x1a\n\tset_owner\x18\x02 \x01(\x0b\x32\x05.UserH\x00\x12\x1f\n\x0bset_channel\x18\x03 \x01(\x0b\x32\x08.ChannelH\x00\x12\x1b\n\x11get_radio_request\x18\x04 \x01(\x08H\x00\x12*\n\x12get_radio_response\x18\x05 \x01(\x0b\x32\x0c.RadioConfigH\x00\x12\x1d\n\x13get_channel_request\x18\x06 \x01(\rH\x00\x12(\n\x14get_channel_response\x18\x07 \x01(\x0b\x32\x08.ChannelH\x00\x12\x1d\n\x13\x63onfirm_set_channel\x18 \x01(\x08H\x00\x12\x1b\n\x11\x63onfirm_set_radio\x18! \x01(\x08H\x00\x42\t\n\x07variantB$\n\x13\x63om.geeksville.meshB\x0b\x41\x64minProtosH\x03\x62\x06proto3'
|
||||
serialized_pb=b'\n\x0b\x61\x64min.proto\x1a\nmesh.proto\x1a\x11radioconfig.proto\x1a\rchannel.proto\"\xe1\x02\n\x0c\x41\x64minMessage\x12!\n\tset_radio\x18\x01 \x01(\x0b\x32\x0c.RadioConfigH\x00\x12\x1a\n\tset_owner\x18\x02 \x01(\x0b\x32\x05.UserH\x00\x12\x1f\n\x0bset_channel\x18\x03 \x01(\x0b\x32\x08.ChannelH\x00\x12\x1b\n\x11get_radio_request\x18\x04 \x01(\x08H\x00\x12*\n\x12get_radio_response\x18\x05 \x01(\x0b\x32\x0c.RadioConfigH\x00\x12\x1d\n\x13get_channel_request\x18\x06 \x01(\rH\x00\x12(\n\x14get_channel_response\x18\x07 \x01(\x0b\x32\x08.ChannelH\x00\x12\x1d\n\x13\x63onfirm_set_channel\x18 \x01(\x08H\x00\x12\x1b\n\x11\x63onfirm_set_radio\x18! \x01(\x08H\x00\x12\x18\n\x0e\x65xit_simulator\x18\" \x01(\x08H\x00\x42\t\n\x07variantB$\n\x13\x63om.geeksville.meshB\x0b\x41\x64minProtosH\x03\x62\x06proto3'
|
||||
,
|
||||
dependencies=[mesh__pb2.DESCRIPTOR,radioconfig__pb2.DESCRIPTOR,channel__pb2.DESCRIPTOR,])
|
||||
|
||||
@@ -129,6 +129,13 @@ _ADMINMESSAGE = _descriptor.Descriptor(
|
||||
message_type=None, enum_type=None, containing_type=None,
|
||||
is_extension=False, extension_scope=None,
|
||||
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||
_descriptor.FieldDescriptor(
|
||||
name='exit_simulator', full_name='AdminMessage.exit_simulator', index=9,
|
||||
number=34, type=8, cpp_type=7, label=1,
|
||||
has_default_value=False, default_value=False,
|
||||
message_type=None, enum_type=None, containing_type=None,
|
||||
is_extension=False, extension_scope=None,
|
||||
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||
],
|
||||
extensions=[
|
||||
],
|
||||
@@ -147,7 +154,7 @@ _ADMINMESSAGE = _descriptor.Descriptor(
|
||||
fields=[]),
|
||||
],
|
||||
serialized_start=62,
|
||||
serialized_end=389,
|
||||
serialized_end=415,
|
||||
)
|
||||
|
||||
_ADMINMESSAGE.fields_by_name['set_radio'].message_type = radioconfig__pb2._RADIOCONFIG
|
||||
@@ -182,6 +189,9 @@ _ADMINMESSAGE.fields_by_name['confirm_set_channel'].containing_oneof = _
|
||||
_ADMINMESSAGE.oneofs_by_name['variant'].fields.append(
|
||||
_ADMINMESSAGE.fields_by_name['confirm_set_radio'])
|
||||
_ADMINMESSAGE.fields_by_name['confirm_set_radio'].containing_oneof = _ADMINMESSAGE.oneofs_by_name['variant']
|
||||
_ADMINMESSAGE.oneofs_by_name['variant'].fields.append(
|
||||
_ADMINMESSAGE.fields_by_name['exit_simulator'])
|
||||
_ADMINMESSAGE.fields_by_name['exit_simulator'].containing_oneof = _ADMINMESSAGE.oneofs_by_name['variant']
|
||||
DESCRIPTOR.message_types_by_name['AdminMessage'] = _ADMINMESSAGE
|
||||
_sym_db.RegisterFileDescriptor(DESCRIPTOR)
|
||||
|
||||
@@ -234,6 +244,10 @@ DESCRIPTOR._options = None
|
||||
<dd>
|
||||
<div class="desc"><p>Field AdminMessage.confirm_set_radio</p></div>
|
||||
</dd>
|
||||
<dt id="meshtastic.admin_pb2.AdminMessage.exit_simulator"><code class="name">var <span class="ident">exit_simulator</span></code></dt>
|
||||
<dd>
|
||||
<div class="desc"><p>Field AdminMessage.exit_simulator</p></div>
|
||||
</dd>
|
||||
<dt id="meshtastic.admin_pb2.AdminMessage.get_channel_request"><code class="name">var <span class="ident">get_channel_request</span></code></dt>
|
||||
<dd>
|
||||
<div class="desc"><p>Field AdminMessage.get_channel_request</p></div>
|
||||
@@ -286,6 +300,7 @@ DESCRIPTOR._options = None
|
||||
<li><code><a title="meshtastic.admin_pb2.AdminMessage.DESCRIPTOR" href="#meshtastic.admin_pb2.AdminMessage.DESCRIPTOR">DESCRIPTOR</a></code></li>
|
||||
<li><code><a title="meshtastic.admin_pb2.AdminMessage.confirm_set_channel" href="#meshtastic.admin_pb2.AdminMessage.confirm_set_channel">confirm_set_channel</a></code></li>
|
||||
<li><code><a title="meshtastic.admin_pb2.AdminMessage.confirm_set_radio" href="#meshtastic.admin_pb2.AdminMessage.confirm_set_radio">confirm_set_radio</a></code></li>
|
||||
<li><code><a title="meshtastic.admin_pb2.AdminMessage.exit_simulator" href="#meshtastic.admin_pb2.AdminMessage.exit_simulator">exit_simulator</a></code></li>
|
||||
<li><code><a title="meshtastic.admin_pb2.AdminMessage.get_channel_request" href="#meshtastic.admin_pb2.AdminMessage.get_channel_request">get_channel_request</a></code></li>
|
||||
<li><code><a title="meshtastic.admin_pb2.AdminMessage.get_channel_response" href="#meshtastic.admin_pb2.AdminMessage.get_channel_response">get_channel_response</a></code></li>
|
||||
<li><code><a title="meshtastic.admin_pb2.AdminMessage.get_radio_request" href="#meshtastic.admin_pb2.AdminMessage.get_radio_request">get_radio_request</a></code></li>
|
||||
|
||||
@@ -380,6 +380,15 @@ class Node:
|
||||
wantResponse=True,
|
||||
onResponse=onResponse)
|
||||
|
||||
def exitSimulator(self):
|
||||
"""
|
||||
Tell a simulator node to exit (this message is ignored for other nodes)
|
||||
"""
|
||||
p = admin_pb2.AdminMessage()
|
||||
p.exit_simulator = True
|
||||
|
||||
return self._sendAdmin(p)
|
||||
|
||||
def _requestChannel(self, channelNum: int):
|
||||
"""
|
||||
Done with initial config messages, now send regular MeshPackets to ask for settings
|
||||
@@ -2434,6 +2443,15 @@ wantResponse – True if you want the service on the other side to send an a
|
||||
wantResponse=True,
|
||||
onResponse=onResponse)
|
||||
|
||||
def exitSimulator(self):
|
||||
"""
|
||||
Tell a simulator node to exit (this message is ignored for other nodes)
|
||||
"""
|
||||
p = admin_pb2.AdminMessage()
|
||||
p.exit_simulator = True
|
||||
|
||||
return self._sendAdmin(p)
|
||||
|
||||
def _requestChannel(self, channelNum: int):
|
||||
"""
|
||||
Done with initial config messages, now send regular MeshPackets to ask for settings
|
||||
@@ -2515,6 +2533,25 @@ def channelURL(self):
|
||||
</dl>
|
||||
<h3>Methods</h3>
|
||||
<dl>
|
||||
<dt id="meshtastic.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):
|
||||
"""
|
||||
Tell a simulator node to exit (this message is ignored for other nodes)
|
||||
"""
|
||||
p = admin_pb2.AdminMessage()
|
||||
p.exit_simulator = True
|
||||
|
||||
return self._sendAdmin(p) </code></pre>
|
||||
</details>
|
||||
</dd>
|
||||
<dt id="meshtastic.Node.getChannelByName"><code class="name flex">
|
||||
<span>def <span class="ident">getChannelByName</span></span>(<span>self, name)</span>
|
||||
</code></dt>
|
||||
@@ -3221,6 +3258,7 @@ hostname {string} – Hostname/IP address of the device to connect to</p></d
|
||||
<h4><code><a title="meshtastic.Node" href="#meshtastic.Node">Node</a></code></h4>
|
||||
<ul class="two-column">
|
||||
<li><code><a title="meshtastic.Node.channelURL" href="#meshtastic.Node.channelURL">channelURL</a></code></li>
|
||||
<li><code><a title="meshtastic.Node.exitSimulator" href="#meshtastic.Node.exitSimulator">exitSimulator</a></code></li>
|
||||
<li><code><a title="meshtastic.Node.getChannelByName" href="#meshtastic.Node.getChannelByName">getChannelByName</a></code></li>
|
||||
<li><code><a title="meshtastic.Node.getDisabledChannel" href="#meshtastic.Node.getDisabledChannel">getDisabledChannel</a></code></li>
|
||||
<li><code><a title="meshtastic.Node.requestConfig" href="#meshtastic.Node.requestConfig">requestConfig</a></code></li>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -29,14 +29,16 @@
|
||||
<pre><code class="python">from . import portnums_pb2, remote_hardware_pb2
|
||||
from pubsub import pub
|
||||
|
||||
|
||||
def onGPIOreceive(packet, interface):
|
||||
"""Callback for received GPIO responses
|
||||
|
||||
|
||||
FIXME figure out how to do closures with methods in python"""
|
||||
pb = remote_hardware_pb2.HardwareMessage()
|
||||
pb.ParseFromString(packet["decoded"]["data"]["payload"])
|
||||
print(f"Received RemoteHardware typ={pb.typ}, gpio_value={pb.gpio_value}")
|
||||
|
||||
|
||||
class RemoteHardwareClient:
|
||||
"""
|
||||
This is the client code to control/monitor simple hardware built into the
|
||||
@@ -51,8 +53,17 @@ class RemoteHardwareClient:
|
||||
iface is the already open MeshInterface instance
|
||||
"""
|
||||
self.iface = iface
|
||||
ch = iface.localNode.getChannelByName("gpio")
|
||||
if not ch:
|
||||
raise Exception(
|
||||
"No gpio channel found, please create before using this (secured) service")
|
||||
self.channelIndex = ch.index
|
||||
|
||||
pub.subscribe(onGPIOreceive, "meshtastic.receive.data.REMOTE_HARDWARE_APP")
|
||||
pub.subscribe(
|
||||
onGPIOreceive, "meshtastic.receive.data.REMOTE_HARDWARE_APP")
|
||||
|
||||
def _sendHardware(self, nodeid, r):
|
||||
return self.iface.sendData(r, nodeid, portnums_pb2.REMOTE_HARDWARE_APP, wantAck=True, channelIndex=self.channelIndex)
|
||||
|
||||
def writeGPIOs(self, nodeid, mask, vals):
|
||||
"""
|
||||
@@ -63,21 +74,21 @@ class RemoteHardwareClient:
|
||||
r.typ = remote_hardware_pb2.HardwareMessage.Type.WRITE_GPIOS
|
||||
r.gpio_mask = mask
|
||||
r.gpio_value = vals
|
||||
return self.iface.sendData(r, nodeid, portnums_pb2.REMOTE_HARDWARE_APP, wantAck = True)
|
||||
return self._sendHardware(nodeid, r)
|
||||
|
||||
def readGPIOs(self, nodeid, mask):
|
||||
"""Read the specified bits from GPIO inputs on the device"""
|
||||
r = remote_hardware_pb2.HardwareMessage()
|
||||
r.typ = remote_hardware_pb2.HardwareMessage.Type.READ_GPIOS
|
||||
r.gpio_mask = mask
|
||||
return self.iface.sendData(r, nodeid, portnums_pb2.REMOTE_HARDWARE_APP, wantAck = True)
|
||||
return self._sendHardware(nodeid, r)
|
||||
|
||||
def watchGPIOs(self, nodeid, mask):
|
||||
"""Watch the specified bits from GPIO inputs on the device for changes"""
|
||||
r = remote_hardware_pb2.HardwareMessage()
|
||||
r.typ = remote_hardware_pb2.HardwareMessage.Type.WATCH_GPIOS
|
||||
r.gpio_mask = mask
|
||||
return self.iface.sendData(r, nodeid, portnums_pb2.REMOTE_HARDWARE_APP, wantAck = True) </code></pre>
|
||||
return self._sendHardware(nodeid, r)</code></pre>
|
||||
</details>
|
||||
</section>
|
||||
<section>
|
||||
@@ -99,7 +110,7 @@ class RemoteHardwareClient:
|
||||
</summary>
|
||||
<pre><code class="python">def onGPIOreceive(packet, interface):
|
||||
"""Callback for received GPIO responses
|
||||
|
||||
|
||||
FIXME figure out how to do closures with methods in python"""
|
||||
pb = remote_hardware_pb2.HardwareMessage()
|
||||
pb.ParseFromString(packet["decoded"]["data"]["payload"])
|
||||
@@ -140,8 +151,17 @@ code for how you can connect to your own custom meshtastic services</p>
|
||||
iface is the already open MeshInterface instance
|
||||
"""
|
||||
self.iface = iface
|
||||
ch = iface.localNode.getChannelByName("gpio")
|
||||
if not ch:
|
||||
raise Exception(
|
||||
"No gpio channel found, please create before using this (secured) service")
|
||||
self.channelIndex = ch.index
|
||||
|
||||
pub.subscribe(onGPIOreceive, "meshtastic.receive.data.REMOTE_HARDWARE_APP")
|
||||
pub.subscribe(
|
||||
onGPIOreceive, "meshtastic.receive.data.REMOTE_HARDWARE_APP")
|
||||
|
||||
def _sendHardware(self, nodeid, r):
|
||||
return self.iface.sendData(r, nodeid, portnums_pb2.REMOTE_HARDWARE_APP, wantAck=True, channelIndex=self.channelIndex)
|
||||
|
||||
def writeGPIOs(self, nodeid, mask, vals):
|
||||
"""
|
||||
@@ -152,21 +172,21 @@ code for how you can connect to your own custom meshtastic services</p>
|
||||
r.typ = remote_hardware_pb2.HardwareMessage.Type.WRITE_GPIOS
|
||||
r.gpio_mask = mask
|
||||
r.gpio_value = vals
|
||||
return self.iface.sendData(r, nodeid, portnums_pb2.REMOTE_HARDWARE_APP, wantAck = True)
|
||||
return self._sendHardware(nodeid, r)
|
||||
|
||||
def readGPIOs(self, nodeid, mask):
|
||||
"""Read the specified bits from GPIO inputs on the device"""
|
||||
r = remote_hardware_pb2.HardwareMessage()
|
||||
r.typ = remote_hardware_pb2.HardwareMessage.Type.READ_GPIOS
|
||||
r.gpio_mask = mask
|
||||
return self.iface.sendData(r, nodeid, portnums_pb2.REMOTE_HARDWARE_APP, wantAck = True)
|
||||
return self._sendHardware(nodeid, r)
|
||||
|
||||
def watchGPIOs(self, nodeid, mask):
|
||||
"""Watch the specified bits from GPIO inputs on the device for changes"""
|
||||
r = remote_hardware_pb2.HardwareMessage()
|
||||
r.typ = remote_hardware_pb2.HardwareMessage.Type.WATCH_GPIOS
|
||||
r.gpio_mask = mask
|
||||
return self.iface.sendData(r, nodeid, portnums_pb2.REMOTE_HARDWARE_APP, wantAck = True) </code></pre>
|
||||
return self._sendHardware(nodeid, r)</code></pre>
|
||||
</details>
|
||||
<h3>Methods</h3>
|
||||
<dl>
|
||||
@@ -184,7 +204,7 @@ code for how you can connect to your own custom meshtastic services</p>
|
||||
r = remote_hardware_pb2.HardwareMessage()
|
||||
r.typ = remote_hardware_pb2.HardwareMessage.Type.READ_GPIOS
|
||||
r.gpio_mask = mask
|
||||
return self.iface.sendData(r, nodeid, portnums_pb2.REMOTE_HARDWARE_APP, wantAck = True)</code></pre>
|
||||
return self._sendHardware(nodeid, r)</code></pre>
|
||||
</details>
|
||||
</dd>
|
||||
<dt id="meshtastic.remote_hardware.RemoteHardwareClient.watchGPIOs"><code class="name flex">
|
||||
@@ -201,7 +221,7 @@ code for how you can connect to your own custom meshtastic services</p>
|
||||
r = remote_hardware_pb2.HardwareMessage()
|
||||
r.typ = remote_hardware_pb2.HardwareMessage.Type.WATCH_GPIOS
|
||||
r.gpio_mask = mask
|
||||
return self.iface.sendData(r, nodeid, portnums_pb2.REMOTE_HARDWARE_APP, wantAck = True) </code></pre>
|
||||
return self._sendHardware(nodeid, r)</code></pre>
|
||||
</details>
|
||||
</dd>
|
||||
<dt id="meshtastic.remote_hardware.RemoteHardwareClient.writeGPIOs"><code class="name flex">
|
||||
@@ -224,7 +244,7 @@ are 1 will be changed</p></div>
|
||||
r.typ = remote_hardware_pb2.HardwareMessage.Type.WRITE_GPIOS
|
||||
r.gpio_mask = mask
|
||||
r.gpio_value = vals
|
||||
return self.iface.sendData(r, nodeid, portnums_pb2.REMOTE_HARDWARE_APP, wantAck = True)</code></pre>
|
||||
return self._sendHardware(nodeid, r)</code></pre>
|
||||
</details>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</summary>
|
||||
<pre><code class="python">import logging
|
||||
from . import util
|
||||
from . import SerialInterface, BROADCAST_NUM
|
||||
from . import SerialInterface, TCPInterface, BROADCAST_NUM
|
||||
from pubsub import pub
|
||||
import time
|
||||
import sys
|
||||
@@ -178,7 +178,26 @@ def testAll():
|
||||
testThread()
|
||||
|
||||
for i in interfaces:
|
||||
i.close()</code></pre>
|
||||
i.close()
|
||||
|
||||
def testSimulator():
|
||||
"""
|
||||
Assume that someone has launched meshtastic-native as a simulated node.
|
||||
Talk to that node over TCP, do some operations and if they are successful
|
||||
exit the process with a success code, else exit with a non zero exit code.
|
||||
|
||||
Run with
|
||||
python3 -c 'from meshtastic.test import testSimulator; testSimulator()'
|
||||
"""
|
||||
logging.basicConfig(level=logging.DEBUG if False else logging.INFO)
|
||||
logging.info("Connecting to simulator on localhost!")
|
||||
iface = TCPInterface("localhost")
|
||||
iface.showInfo()
|
||||
iface.localNode.showInfo()
|
||||
iface.localNode.exitSimulator()
|
||||
iface.close()
|
||||
logging.info("Integration test successful!")
|
||||
sys.exit(0)</code></pre>
|
||||
</details>
|
||||
</section>
|
||||
<section>
|
||||
@@ -405,6 +424,39 @@ toInterface {[type]} – [description]</p>
|
||||
return False # Failed to send</code></pre>
|
||||
</details>
|
||||
</dd>
|
||||
<dt id="meshtastic.test.testSimulator"><code class="name flex">
|
||||
<span>def <span class="ident">testSimulator</span></span>(<span>)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<div class="desc"><p>Assume that someone has launched meshtastic-native as a simulated node.
|
||||
Talk to that node over TCP, do some operations and if they are successful
|
||||
exit the process with a success code, else exit with a non zero exit code.</p>
|
||||
<p>Run with
|
||||
python3 -c 'from meshtastic.test import testSimulator; testSimulator()'</p></div>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def testSimulator():
|
||||
"""
|
||||
Assume that someone has launched meshtastic-native as a simulated node.
|
||||
Talk to that node over TCP, do some operations and if they are successful
|
||||
exit the process with a success code, else exit with a non zero exit code.
|
||||
|
||||
Run with
|
||||
python3 -c 'from meshtastic.test import testSimulator; testSimulator()'
|
||||
"""
|
||||
logging.basicConfig(level=logging.DEBUG if False else logging.INFO)
|
||||
logging.info("Connecting to simulator on localhost!")
|
||||
iface = TCPInterface("localhost")
|
||||
iface.showInfo()
|
||||
iface.localNode.showInfo()
|
||||
iface.localNode.exitSimulator()
|
||||
iface.close()
|
||||
logging.info("Integration test successful!")
|
||||
sys.exit(0)</code></pre>
|
||||
</details>
|
||||
</dd>
|
||||
<dt id="meshtastic.test.testThread"><code class="name flex">
|
||||
<span>def <span class="ident">testThread</span></span>(<span>numTests=50)</span>
|
||||
</code></dt>
|
||||
@@ -451,6 +503,7 @@ toInterface {[type]} – [description]</p>
|
||||
<li><code><a title="meshtastic.test.subscribe" href="#meshtastic.test.subscribe">subscribe</a></code></li>
|
||||
<li><code><a title="meshtastic.test.testAll" href="#meshtastic.test.testAll">testAll</a></code></li>
|
||||
<li><code><a title="meshtastic.test.testSend" href="#meshtastic.test.testSend">testSend</a></code></li>
|
||||
<li><code><a title="meshtastic.test.testSimulator" href="#meshtastic.test.testSimulator">testSimulator</a></code></li>
|
||||
<li><code><a title="meshtastic.test.testThread" href="#meshtastic.test.testThread">testThread</a></code></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
2
proto
2
proto
Submodule proto updated: 943c3c24ec...b8c0499f28
2
setup.py
2
setup.py
@@ -12,7 +12,7 @@ with open("README.md", "r") as fh:
|
||||
# This call to setup() does all the work
|
||||
setup(
|
||||
name="meshtastic",
|
||||
version="1.2.10",
|
||||
version="1.2.11",
|
||||
description="Python API & client shell for talking to Meshtastic devices",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
|
||||
Reference in New Issue
Block a user