Join Now
Quality Rating:
  • Currently 0.0 / 5
(0.0 / 5 - 0 votes cast)
Expertise Level:
  • Currently 0.0 / 5
(0.0 / 5 - 0 votes cast)

This page was last modified 22:27, 12 October 2007.

How to control a robot from PyS60

From Forum Nokia Wiki


To control a robot, we need a server, a client and a protocol to communicate between both.


In this example, I use some piece of source from a project of mine called myMRC (my Mobile Remote Control). Initially the project uses a mobile client written in Java, but soon the use of Python on the phone was more than great for debugging the server.


We have the following environment:

  • Server: Linux Debian machine with Gnome.
  • Client: S60 phone with PyS60 installed.
  • The robot: we will control here a multimedia player (Totem) but it would be the same server structure to control any kind of robot.


Contents

Communication protocol

We came out with the following solution to send/receive commands, arguments, data(XML)...

Packet format:
 
-------------
 
+-------------------------------------------------------+-----------//------------+
 
|                       header                          |          Data           |
 
+-----------+-------------------------------------------+-----------//------------+
 
| 0 1 | 2 3 | 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | 20 21 22 ... Max length |
 
+-----------+-------------------------------------------+-----------//------------+
 
|  OPCode   |                Data length                |          Data           |
 
+-----------+-------------------------------------------+-----------//------------+
  • OPCode:

The opcode has 4 characters. The 2 first characters define the group i.e: the robot you want to control for example. Then the 2 second characters define the command. With this structure we have up to 100 groups of 100 commands, which let's us big possibilities to control about anything.


  • Data length: 16 characters are reserved to the data length. So the structure would fit also for enormous files!


  • Data: starting from character 20 to the end of the packet we have the data.


If the header + data exceed the maximum length for the packet, the first one will be sent with the header and all others without since when parsing the header, the client or the server will expect a certain amount of data to be received.


The server

Specification

Configuration files

All relevant configuration data will be stored in configuration files. (mymrc.conf, players.conf)


Concurrent connections

One of the specifications was to have concurrent connection to control the same player or different robot at the same time. A simple case is that if one is using his/hers mobile phone to control the robot but wants to switch to his/hers PDA, their is no need to disconnect the phone. Event better! If your server has the ability to pilot a USB device to control the coffee machine, you can activate it when you leave the office even if someone is changing the music at home... Only your imagination puts limits :)


Cross platform server

The server is written in Python since it is much easier to implement and also it is cross-platfrom.


The server has been under developed on Linux at the moment so it won't work on Windows machines (the controller part). Not just yet. But you can find some source to adapt like PyS60RC


Source code

mymrc.conf
[inet]
#interface=eth0
port=1233
host=11.2.1.4
maxclient=5
 
[bluetooth]
#interface=eth0
# 0 to 9
port=1
host=localhost
 
[database]
dbname=mymrcdb
 
[players]
audio=totem
#video=
#cd=
#dvd=
 
[settings]
printdebug = yes
language=en
audio=1
video=0
dvd=0
tv=0
 
# do not modify unless you know what you are doing!
[protocol]
BufferSize=1024
DataLength=16


players.conf
[totem]
start=totem
info=totem gnome player
play =--play 
stop=--pause
pause=--play-pause
next=--next
close=--quit
previous=--previous
forward=--seek-fwd
backward=--seek-bwd
volume-up=--volume-up
volume-down=--volume-down
fullscreen=--fullscreen
toogle-controls=--toggle-controls
enqueue=--enqueue
replace=--replace


mymrcd.py
## myMRC deamon
#  @author LEFEVRE Damien
#  @version 0.1
#  @decription myMRC server daemon
from ConfigParser import ConfigParser
from popen2 import popen3 as popen
import thread, threading, socket
import os, sys
 
 
 
welcomeScreen = """********************************************************************************
                                Welcome to myMRC
                                       by:
                          GREGORI Sven & LEFEVRE Damien
                              http://www.mymrc.org
********************************************************************************
"""
 
debug = True
 
## Trace debugging messages.
#  @param aString String to be printed.
def printd( aString ):
    if debug:
        print aString
 
## Operation code dictionary.
class OPCode( dict ):
    ## The constructor
    #  @param self: The object pointer.
    def __init__( self ):
        dict.__init__(self)
        self["EV_NULL"]  = "0000"
        self["EV_INFO"]  = "0100"
        self["EV_PLAY"]  = "0101"
        self["EV_STOP"]  = "0102"
        self["EV_PAUSE"] = "0103"
        self["EV_NEXT"]  = "0104"
        self["EV_CLOSE"]  = "0105"
        self["EV_PREVIOUS"]  = "0106"
        self["EV_FORWARD"]  = "0107"
        self["EV_BACKWARD"]  = "0108"
        self["EV_VOLUMEUP"]  = "0109"
        self["EV_VOLUMEDOWN"]  = "0110"
        self["EV_FULLSCREEN"]  = "0111"
        self["EV_TOOGLECONTROLS"]  = "0112"
 
 
###############################################################################
# Settings classes
###############################################################################
 
## Settings class.
#  Used to retrieve settings from the configuration file.
class Players( object ):
    ## Stores the unique Singleton instance-
    _iInstance = None
    
    ## Players class
    class PlayersClass( ConfigParser ):
        # The constructor.
        #  @param self: The object pointer
        def __init__( self, aCwd ):
            ConfigParser.__init__( self )
            try:
                self.readfp( open( os.path.join( os.getcwd(), "players.conf" )))
            except IOError, error:
                print "players.conf is missing."
                sys.exit(1)
                
    ## The constructor
    #  @param self The object pointer.
    def __init__( self, aCwd=None ):
        # Check whether we already have an instance
        if Players._iInstance is None:
            # Create and remember instanc
            Players._iInstance = Players.PlayersClass(aCwd)
 
        # Store instance reference as the only member in the handle
        self.__dict__['_EventHandler_instance'] = Players._iInstance
    
    
    ## Delegate access to implementation.
    #  @param self The object pointer.
    #  @param attr Attribute wanted.
    #  @return Attribute
    def __getattr__(self, aAttr):
        return getattr(self._iInstance, aAttr)
 
 
    ## Delegate access to implementation.
    #  @param self The object pointer.
    #  @param attr Attribute wanted.
    #  @param value Vaule to be set.
    #  @return Result of operation.
    def __setattr__(self, aAttr, aValue):
        return setattr(self._iInstance, aAttr, aValue )      
 
 
## Settings class.
#  Used to retrieve settings from the configuration file.
class Settings( object ):
    ## Stores the unique Singleton instance-
    _iInstance = None
        
    class SettingsClass( ConfigParser ):
        # The constructor.
        #  @param self: The object pointer
        def __init__( self, aCwd ):
            ConfigParser.__init__( self )
            try:
                self.readfp( open( os.path.join( os.getcwd(), "mymrc.conf" ) )) 
            except IOError, error:
                print "mymrc.conf is missing"
                sys.exit(1)
                
            if not self.has_section( "database" ):
                DatabaseSectionError( )
            
            
        
    ## The constructor
    #  @param self The object pointer.
    def __init__( self, aCwd=None ):
        # Check whether we already have an instance
        if Settings._iInstance is None:
            # Create and remember instanc
            Settings._iInstance = Settings.SettingsClass(aCwd)
 
        # Store instance reference as the only member in the handle
        self.__dict__['_EventHandler_instance'] = Settings._iInstance
    
    
    ## Delegate access to implementation.
    #  @param self The object pointer.
    #  @param attr Attribute wanted.
    #  @return Attribute
    def __getattr__(self, aAttr):
        return getattr(self._iInstance, aAttr)
 
 
    ## Delegate access to implementation.
    #  @param self The object pointer.
    #  @param attr Attribute wanted.
    #  @param value Vaule to be set.
    #  @return Result of operation.
    def __setattr__(self, aAttr, aValue):
        return setattr(self._iInstance, aAttr, aValue )
 
 
###############################################################################
# Protocol
###############################################################################
 
"""
Packet format:
-------------
+-----------------------------------------------------+-----------//------------+
|                      header                         |          Data           |
+---------+-------------------------------------------+-----------//------------+
| 0 1 2 3 | 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | 20 21 22 ... Max length |
+---------+-------------------------------------------+-----------//------------+
| OPCode  |                Data length                |          Data           |
+---------+-------------------------------------------+-----------//------------+
"""   
 
## Parse and build packets
#
class Packet( object ):
    ## Packet size.
    _iBufferSize = 1024
    ## Data length
    _iDataLength = 16
 
    def __init__(self):
        self._iBufferSize = int(Settings().get("protocol", "BufferSize"))
        self._iDataLength = int(Settings().get("protocol", "DataLength"))
    
    ## Get the data length with the proper formating (16 characters value).
    #  @param self: The object pointer.
    #  @return: 16 characters formated length.
    def _formatedLength( self, aData ):
        length = str( len( aData ) )
        formatedLength = ""
        i = 0
        for i in range( self._iDataLength - len( length ) ):
            formatedLength += "0"
        formatedLength += length
        return formatedLength
    
    
## Packet builder class.
class PacketBuilder( Packet ):
    ## Build a packet to be sent to the client.
    #  @param self: The obkect pointer
    #  @param aOpCode: Operation code
    #  @param aData: Data to be sent.
    #  @return: Packet to be sent.
    def build(self, aOpCode, aData):
        packet = ""
        packet += str( aOpCode )
        packet += self._formatedLength( aData )
        packet += str( aData )
        return packet
 
## Packet parser class.
class PacketParser(Packet):
    ## Parse incoming buffer.
    #  @param self: The object pointer.
    #  @param aBuffer: buffer coming from the server
    #  @return: opCode, length and data as strings.
    def parse(self, aBuffer):
        opCode = aBuffer[0:4]
        length = aBuffer[4:20]
        data = aBuffer[20:]
        return (opCode, length, data)  
  
  
###############################################################################
# Networking
###############################################################################    
 
# Inet socket server
class InetServerSocket( socket.socket ):
    
    ## The constructor.
    #  @param self: The object pointer.
    #  @param aHost: Host name to connect to.
    #  @param aPort: Port on the host to bind.
    #  @param aMaxClient: Maximum number of concurrent connection.
    def __init__( self, aHost, aPort, aMaxClient ):
        socket.socket.__init__( self, socket.AF_INET, socket.SOCK_STREAM )
        self.bind ( ( str(aHost), int(aPort) ) )
        self.listen ( int(aMaxClient) )
        self._iLock = threading.Semaphore()
        self._iClientStack = []
        self._iMaxClient = int(aMaxClient)
        printd("Inet interface: (%s:%s) - concurrent connection: %s"%(aHost, 
                                                                     aPort, 
                                                                     aMaxClient))
 
 
    ## Add instance to the array.
    #  @param self: The object pointer.
    #  @param aChannel: Channel to had the the array.
    def _addToStack( self, aClient ):
        self._iLock.acquire( )
        self._iClientStack.append( aClient )
        self._iLock.release( )
        printd( "Client added to stack")
        
        
    ## Remove instance from array
    #  @param self: The object pointer
    #  @param aChannel: Channel to remove from the queue
    def _removeFromStack( self, aClient ):
        self._iLock.acquire( )
        self._iClientStack.remove( aClient )
        self._iLock.release( )
        printd( "Channel removed from stack." )
                    
                    
    ## Watch for channel incoming requests.
    #  @param self: The object pointer.
    #  @param aChannel: Channel to remove from the queue.
    def _channelWatcher( self, aChannel ):
        while 1:
            data = aChannel.recv ( self._iBufferSize )
            if not data: break
            else:
                #retVal = self._mCallback( data )
                #retVal = EventHandler().event(data)
                #if retVal: aChannel.send ( retVal )
                pass
                    
        self._removeFromStack( aChannel )
        aChannel.close( )
        printd("Connection closed: " + str( aChannel ) )
    
    
    ## Handle incomming connection request.
    #  @param self: The object pointer.
    def _connectionHandler( self ):
        printd("Wait for incoming connection")
        while True:
            if len(self._iClientStack) in range( self._iMaxClient ):
                #channel, details = self.accept()
                #printd( 'New connection with: ' + str(details) )
                # new client
                client = Client(self.accept())
                self._addToStack( client )
                #self._addToStack( channel )
                
 
                #channel.setblocking( 1 )
                #thread.start_new_thread( self._channelWatcher, ( channel, ) )
            else:
                printd("Maximum concurrent connection exceeded. Close client \
                    connection before creating a new one")        
    
        
    ## Listen interface for incomming connection.
    #  @param self The object pointer.
    def start( self ):
        thread.start_new_thread(self._connectionHandler, () )
    
        
    ## Send data
    #  @param self The object pointer.
    #  @param aFrame Frame.    
    def send( self, aFrame):
        pass         
    
    def __del__(self):
        for client in self._iClientStack:
            self._removeFromStack(client)
            del client
 
## Inet server singletong
class InetServer( object ): 
    ## Stores the unique Singleton instance-
    _iInstance = None   
 
    ## Inet server class declaration
    class InetServerClass:
  
        ## The constructor.
        #  @param self: The object pointer.
        def __init__( self ):
            self._iSocketServer = None
    
        ## Start the server.
        #  @param self The object pointer.
        def start(self):
            self._iSocketServer = InetServerSocket(
                                            Settings().get("inet","host"),
                                            Settings().get("inet","port"),
                                            Settings().get("inet","maxclient"))
            self._iSocketServer.start()
            printd("InetServer started")
        
        ## Stop the server.
        #  @param self The object pointer.
        def stop(self):
            if self._iSocketServer:
                del self._iSocketServer
                self._iSocketServer = None
                printd("InetServer stopped")
        
        ## Restart server.
        #  @param self The object pointer.    
        def restart(self):
            printd("Restarting InetServer")
            self.stop()
            self.start()
           
       
        ## The destructor
        # @param self: The object pointer
        def __del__( self ):
            self.stop()
 
    ###########################################################################
    # Singleton accessors
    ###########################################################################
                
    ## The constructor
    #  @param self The object pointer.
    def __init__( self ):
        # Check whether we already have an instance
        if InetServer._iInstance is None:
            # Create and remember instanc
            InetServer._iInstance = InetServer.InetServerClass()
 
        # Store instance reference as the only member in the handle
        self.__dict__['_EventHandler_instance'] = InetServer._iInstance
    
    
    ## Delegate access to implementation.
    #  @param self The object pointer.
    #  @param attr Attribute wanted.
    #  @return Attribute
    def __getattr__(self, aAttr):
        return getattr(self._iInstance, aAttr)
 
 
    ## Delegate access to implementation.
    #  @param self The object pointer.
    #  @param attr Attribute wanted.
    #  @param value Vaule to be set.
    #  @return Result of operation.
    def __setattr__(self, aAttr, aValue):
        return setattr(self._iInstance, aAttr, aValue )      
 
 
###############################################################################
# Controlers
###############################################################################
class Controller( object ):
    def __init__(self):
        if sys.platform == 'win32':
            self._shell, self._tail = ('cmd', '\r\n')
        else:
            self._shell, self._tail = ('sh', '\n')
    
      
    def cmd( self, aCommand ):
        try:
            printd(aCommand)
            popen(aCommand+self._tail)
        except Exception, error:
            printd('AudioController: '+ repr(error))
            
        return '0000', 'error'
 
class AudioController( Controller, Players ):
    def __init__(self):
        Controller.__init__(self)
        Players.__init__(self)
        self._iPlayer = Settings().get("players", "audio")
        printd("Audio player: " + self._iPlayer)
        self._iNBSP = " "
        
    def play(self, aFile=None):
        command = self.get(self._iPlayer, "start") + self._iNBSP + \
        self.get(self._iPlayer, "play") + self._iNBSP + \
        "\"%s\"" % aFile
 
        thread.start_new(self.cmd, (command,))
        return "Play"
 
    def stop(self):
        command = self.get(self._iPlayer, "start")+ \
                             self._iNBSP+self.get(self._iPlayer, "stop")
        return self.cmd(command)
        
    def pause(self):
        command = self.get(self._iPlayer, "start")+\
                             self._iNBSP+self.get(self._iPlayer, "pause")
        return self.cmd(command)
    
    def next(self):
        command = self.get(self._iPlayer, "start")+\
                             self._iNBSP+self.get(self._iPlayer, "next")
        return self.cmd(command)
    
    def close(self):
        command = self.get(self._iPlayer, "start")+\
                             self._iNBSP+self.get(self._iPlayer, "close")
        return self.cmd(command)
    
    def previous(self):
        command = self.get(self._iPlayer, "start")+\
                             self._iNBSP+self.get(self._iPlayer, "previous")
        return self.cmd(command)
    
    def forward(self):
        command = self.get(self._iPlayer, "start")+\
                             self._iNBSP+self.get(self._iPlayer, "forward")
        return self.cmd(command)
    
    def backward(self):
        command = self.get(self._iPlayer, "start")+\
                             self._iNBSP+self.get(self._iPlayer, "backward")
        return self.cmd(command)
 
    def volume_up(self):
        command = self.get(self._iPlayer, "start")+\
                             self._iNBSP+self.get(self._iPlayer, "volume-up")
        return self.cmd(command)
 
    ## volume down.
    #  @callgraph
    #  @callergraph
    def volume_down(self):
        command = self.get(self._iPlayer, "start")+\
                             self._iNBSP+self.get(self._iPlayer, "volume-down")
        return self.cmd(command)
     
    def fullscreen(self):
        command = self.get(self._iPlayer, "start")+\
                             self._iNBSP+self.get(self._iPlayer, "fullscreen")
        return self.cmd(command)
     
    def hide_show_controls(self):
        command = self.get(self._iPlayer, "start")+\
                             self._iNBSP+self.get(self._iPlayer, "toogle-controls")
        return self.cmd(command)
   
    def __del__(self):
        printd("AudioController: Destructor")
        self.close()
 
 
###############################################################################
# Client
###############################################################################
 
class Client:
    def __init__(self, aConnection ):
        self._iConn = aConnection[0]
        self._iConn.setblocking( 1 )
        self._iObserverPid = 0
        self._iBufferSize = int(Settings().get("protocol", "BufferSize"))
        print self._iBufferSize
        self._iAudioController = AudioController()
        self.start_observer()
        printd( 'New connection with: ' + str(aConnection[1]) )
 
    def _observer(self):
        # set PID to be able to kill the thread
        try:
            self._iObserverPid = os.getpid()
        except:
            pass
        while 1:
            data = self._iConn.recv( self._iBufferSize )
            if not data: break
            else:
                retVal = self._handle_event(data)  
                if retVal: self.send(PacketBuilder().build(retVal[0], retVal[1]))              
        self._iConn.close( )
        self._iObserverPid = 0
        printd("Connection closed: " + str( self._iConn ) )        
    
    def send(self, aString):
        self._iConn.sendall(aString, self._iBufferSize)
    
    def receive(self):
        pass
   
    def start_observer(self):
        print "start_observer(self):"
        thread.start_new_thread(self._observer, () )
    
    def stop_observer(self):
        if self._iObserverPid:
            os.popen("kill -9 "+str(self._iObserverPid))
    
    def _handle_event(self, aData):
        opcode, length, data = PacketParser().parse(aData)
       
        if opcode == OPCode()['EV_INFO']:
            self._iAudioController.play()
            return OPCode()['EV_NULL'], "info"
        
        elif opcode ==  OPCode()["EV_PLAY"]:
            song = "/home/storage/Music/Counting Crows - Mr. Jones.mp3"
            self._iAudioController.play( song )
            return OPCode()['EV_NULL'], "Playing"
        
        elif opcode ==  OPCode()["EV_STOP"]:
            self._iAudioController.stop()
            return OPCode()['EV_NULL'], "Stopped"
        
        elif opcode ==  OPCode()["EV_PAUSE"]:
            self._iAudioController.pause()
            return OPCode()['EV_NULL'], "Play/Pause"
        
        elif opcode ==  OPCode()["EV_NEXT"]:
            self._iAudioController.next()
            return OPCode()["EV_NULL"], "Next"
        
        elif opcode ==  OPCode()["EV_CLOSE"]:
            self._iAudioController.close()
            return OPCode()['EV_NULL'], "Closed"
        
        elif opcode ==  OPCode()["EV_PREVIOUS"]:
            self._iAudioController.previous()
            return OPCode()['EV_NULL'], "Previous"
        
        elif opcode ==  OPCode()["EV_FORWARD"]:
            self._iAudioController.forward()
            return OPCode()['EV_NULL'], "Forward"
        
        elif opcode ==  OPCode()["EV_BACKWARD"]:
            self._iAudioController.backward()
            return OPCode()['EV_NULL'], "Backward"
        
        elif opcode ==  OPCode()["EV_VOLUMEUP"]:
            self._iAudioController.volume_up()
            return OPCode()['EV_NULL'], "Volume up"
        
        elif opcode ==  OPCode()["EV_VOLUMEDOWN"]:
            self._iAudioController.volume_down()
            return OPCode()['EV_NULL'], "Volume down"
        
        elif opcode ==  OPCode()["EV_FULLSCREEN"]:
            self._iAudioController.fullscreen()
            return OPCode()['EV_NULL'], "Fullscreen"
        
        elif opcode ==  OPCode()["EV_TOOGLECONTROLS"]:
            self._iAudioController.hide_show_controls()
            return OPCode()['EV_NULL'], "Toogle controls"
        else:
            return "0000", ''
    
    
    
    def __del__(self):
        self._iEngine()
        self._iConn.close()
 
 
###############################################################################
# Utils
###############################################################################
class App_lock:
    ## The constructor.
    #  @param self: The object pointer.
    def __init__( self ):
        ## class semaphore 
        self._iAppLock = threading.Semaphore()
        self._iAppLock.acquire()
        
    ## Wait forever until the lock is signaled.
    #  @param self: The object pointer.
    def wait( self ):
        # Wait forever to acquire the semaphore.
        self._iAppLock.acquire(True)
        # Release semaphore; the application exit.
        self._iAppLock.release()
    
    ## Signal the lock to exit the application.
    #  @param self: The object pointer.
    def signal(self):
        self._iAppLock.release()
 
## myMRC daemon server class.
class MyMRCd:
    
    ## The constructor.
    #  @param self The object pointer. 
    def __init__(self):
        print welcomeScreen
        # Set the confile file path
        Settings( os.getcwd() )
        Players( os.getcwd() )
 
    ## Run the server
    #  @param self The object pointer.
    def run(self):
        InetServer().start()
 
 
    ## Destructor. Stops the networking servers
    #  @param self The object pointer.
    def __del__(self):
        InetServer().stop()
    
    
if __name__ == '__main__':
    APP_LOCK = App_lock()
    myMRC = MyMRCd()
    myMRC.run()
    APP_LOCK.wait( )


The PyS60 client

Here come a minimalistic client meant to debug the server.

Make sure to put an exact copy of the opcode dictionary on the client and the server.

With this script below, you will have to set the 'ip' and 'port' to connect to on the server as well as a valid video or audio file in 'play'


Source code

MyMRCpy.py
## myMRC deamon
#  @author LEFEVRE Damien
#  @version 0.1
#  @decription myMRC server daemon
from ConfigParser import ConfigParser
from popen2 import popen3 as popen
import thread, threading, socket
import os, sys
 
 
 
welcomeScreen = """********************************************************************************
                                Welcome to myMRC
                                       by:
                          GREGORI Sven & LEFEVRE Damien
                              http://www.mymrc.org
********************************************************************************
"""
 
debug = True
 
## Trace debugging messages.
#  @param aString String to be printed.
def printd( aString ):
    if debug:
        print aString
 
## Operation code dictionary.
class OPCode( dict ):
    ## The constructor
    #  @param self: The object pointer.
    def __init__( self ):
        dict.__init__(self)
        self["EV_NULL"]  = "0000"
        self["EV_INFO"]  = "0100"
        self["EV_PLAY"]  = "0101"
        self["EV_STOP"]  = "0102"
        self["EV_PAUSE"] = "0103"
        self["EV_NEXT"]  = "0104"
        self["EV_CLOSE"]  = "0105"
        self["EV_PREVIOUS"]  = "0106"
        self["EV_FORWARD"]  = "0107"
        self["EV_BACKWARD"]  = "0108"
        self["EV_VOLUMEUP"]  = "0109"
        self["EV_VOLUMEDOWN"]  = "0110"
        self["EV_FULLSCREEN"]  = "0111"
        self["EV_TOOGLECONTROLS"]  = "0112"
 
 
###############################################################################
# Settings classes
###############################################################################
 
## Settings class.
#  Used to retrieve settings from the configuration file.
class Players( object ):
    ## Stores the unique Singleton instance-
    _iInstance = None
    
    ## Players class
    class PlayersClass( ConfigParser ):
        # The constructor.
        #  @param self: The object pointer
        def __init__( self, aCwd ):
            ConfigParser.__init__( self )