sr_edc_ethercat_drivers Documentation

sr_edc_ethercat_drivers

A package implementing a ROS interface for the etherCAT Shadow Robot Dextrous Hand.

sr_edc_ethercat_drivers is the low level communication library that allows you to communicate with the SR EDC EtherCAT hand.

This package takes care of the low level EtherCAT protocol, it sends data to the PIC32 which is on the palm, and receives back informations from it.

It implements the driver for the Ethercat slave device which is on the SR EDC EtherCAT hand.

It is implemented as plugins.

When the pr2_etherCAT node runs, it discovers the EtherCAT slaves which are present on the EtherCAT bus, and loads dynamically the class needed to control each slave.

This package implements those classes, to control the EtherCAT slaves on the SR EDC EtherCAT hand, and links them into a library so that pr2_etherCAT can load driver classes from it.

This document will describe how the EtherCAT driver works, but first we have to understand how the pr2_etherCAT node works.

Where to find what ?

How to burn the PIC18F CAN bootloader ?

How to flash a firmware using the CAN bootloader ?

root@rosethercat:~# roslaunch sr_edc_launch sr_edc.launch
rosservice call SimpleMotorFlasher "/home/hand/test-firmware.hex" 8

This means we call the "SimpleMotorFlasher" service, and tell it to flash the "test-firmware.hex" firmware to the motor board number 8.

At the moment the flashing procedure has been tested with 2 firmwares :

BEWARE : the firmware you flash has to be "bootloader aware" in order to allow future re-flashing. Upon reception of a Magic Packet it has to :

If the firmware you flash does not support the Magic Packet, then you won't be able to bootload anymore, untill you overwrite yourself the last location in EEPROM with 0xFF and restart the motor pcb.

The current SimpleMotor firmware is bootloader aware and therefore can be flashed over itself or overwritten by something else without any problem.

How to do a bootloader-aware firmware ?

There is basically three things to do in order to do a bootloader-aware firmware :

Here is the linkerscript used for the SimpleMotor and led-blinker firmwares :

#DEFINE _CODEEND _DEBUGCODESTART - 1
#DEFINE _CEND _CODEEND + _DEBUGCODELEN
#DEFINE _DATAEND _DEBUGDATASTART - 1
#DEFINE _DEND _DATAEND + _DEBUGDATALEN

LIBPATH .

FILES clib.lib

// do not need anymore, its included in the project
//FILES c018i-userapp.o


FILES p18f2580.lib

CODEPAGE NAME=startup START=0x0 END=0x5 PROTECTED

CODEPAGE NAME=boot START=0x8 END=0x47F PROTECTED

CODEPAGE NAME=vectors START=0x480 END=0x49F PROTECTED
#IFDEF _DEBUGCODESTART
  CODEPAGE   NAME=page       START=0x4c0               END=_CODEEND
  CODEPAGE   NAME=debug      START=_DEBUGCODESTART   END=_CEND        PROTECTED
#ELSE
  CODEPAGE   NAME=page       START=0x4c0               END=0x7FFF
#FI
CODEPAGE NAME=idlocs START=0x200000 END=0x200007 PROTECTED
CODEPAGE NAME=config START=0x300000 END=0x30000D PROTECTED
CODEPAGE NAME=devid START=0x3FFFFE END=0x3FFFFF PROTECTED
CODEPAGE NAME=eedata START=0xF00000 END=0xF000FF PROTECTED
ACCESSBANK NAME=accessram START=0x0 END=0x5F
DATABANK NAME=gpr0 START=0x80 END=0xFF
DATABANK NAME=gpr1 START=0x100 END=0x1FF
DATABANK NAME=gpr2 START=0x200 END=0x2FF
DATABANK NAME=gpr3 START=0x300 END=0x3FF
DATABANK NAME=gpr4 START=0x400 END=0x4FF
#IFDEF _DEBUGDATASTART
  DATABANK   NAME=gpr5       START=0x500             END=_DATAEND
  DATABANK   NAME=dbgspr     START=_DEBUGDATASTART   END=_DEND           PROTECTED
#ELSE //no debug
  DATABANK   NAME=gpr5       START=0x500             END=0x5FF
#FI
ACCESSBANK NAME=accesssfr START=0xF60 END=0xFFF PROTECTED


DATABANK   NAME=sfr13      START=0xD00             END=0xDFF          PROTECTED
DATABANK   NAME=sfr14      START=0xE00             END=0xEFF          PROTECTED
DATABANK   NAME=sfr15      START=0xF00             END=0xF5F          PROTECTED


#IFDEF _CRUNTIME
  SECTION    NAME=CONFIG     ROM=config
  #IFDEF _DEBUGDATASTART
    STACK SIZE=0x100 RAM=gpr4
  #ELSE
    STACK SIZE=0x100 RAM=gpr5
  #FI
#FI

Note : this file is located at svn://pericles/EDC/simplemotor/simplemotor.lkr

Here is the startup code you will need to put in your project :

/* $Id: c018i.c,v 1.7 2006/11/15 22:53:12 moshtaa Exp $ */

/* Copyright (c)1999 Microchip Technology */
/* Modified by Yann Sionneau in 2011 */

/* MPLAB-C18 startup code, including initialized data */

/* external reference to __init() function */
extern void __init (void);
/* external reference to the user's main routine */
extern void main (void);
/* prototype for the startup function */
void _entry (void);
void _startup (void);
/* prototype for the initialized data setup */
void _do_cinit (void);

extern volatile near unsigned long short TBLPTR;
extern near unsigned FSR0;
extern near char __FPFLAGS;
#define RND 6

#pragma code _entry_scn=0x0004c0
void
_entry (void)
{
_asm goto _startup _endasm

}
#pragma code _startup_scn
void
_startup (void)
{
  _asm
    // Initialize the stack pointer
    lfsr 1, _stack
    lfsr 2, _stack

    clrf TBLPTRU, 0 // 1st silicon doesn't do this on POR

    bcf __FPFLAGS,RND,0 // Initialize rounding flag for floating point libs
    
    _endasm 
    _do_cinit ();

loop:

  // If user defined __init is not found, the one in clib.lib will be used
  __init ();

  // Call the user's main routine
  main ();

  goto loop;
}                               /* end _startup() */

/* MPLAB-C18 initialized data memory support */
/* The linker will populate the _cinit table */
extern far rom struct
{
  unsigned short num_init;
  struct _init_entry
  {
    unsigned long from;
    unsigned long to;
    unsigned long size;
  }
  entries[];
}
_cinit;

#pragma code _cinit_scn
void
_do_cinit (void)
{
  /* we'll make the assumption in the following code that these statics
 will be allocated into the same bank.
   */
  static short long prom;
  static unsigned short curr_byte;
  static unsigned short curr_entry;
  static short long data_ptr;

  // Initialized data...
  TBLPTR = (short long)&_cinit;
  _asm
    movlb data_ptr
    tblrdpostinc
    movf TABLAT, 0, 0
    movwf curr_entry, 1
    tblrdpostinc
    movf TABLAT, 0, 0
    movwf curr_entry+1, 1
  _endasm
    //while (curr_entry)
    //{
    test:
    _asm
     bnz 3
    tstfsz curr_entry, 1
    bra 1
    _endasm
    goto done;
      /* Count down so we only have to look up the data in _cinit
 once.

 At this point we know that TBLPTR points to the top of the current
 entry in _cinit, so we can just start reading the from, to, and
 size values.
       */
      _asm
        /* read the source address */
        tblrdpostinc
        movf TABLAT, 0, 0
        movwf prom, 1
        tblrdpostinc
        movf TABLAT, 0, 0
        movwf prom+1, 1
        tblrdpostinc
        movf TABLAT, 0, 0
        movwf prom+2, 1
        /* skip a byte since it's stored as a 32bit int */
        tblrdpostinc
        /* read the destination address directly into FSR0 */
        tblrdpostinc
        movf TABLAT, 0, 0
        movwf FSR0L, 0
        tblrdpostinc
        movf TABLAT, 0, 0
        movwf FSR0H, 0
        /* skip two bytes since it's stored as a 32bit int */
        tblrdpostinc
        tblrdpostinc
        /* read the destination address directly into FSR0 */
        tblrdpostinc
        movf TABLAT, 0, 0
        movwf curr_byte, 1
        tblrdpostinc
        movf TABLAT, 0, 0
        movwf curr_byte+1, 1
        /* skip two bytes since it's stored as a 32bit int */
        tblrdpostinc
        tblrdpostinc
      _endasm  
      //prom = data_ptr->from;
      //FSR0 = data_ptr->to;
      //curr_byte = (unsigned short) data_ptr->size;
      /* the table pointer now points to the next entry. Save it
 off since we'll be using the table pointer to do the copying
 for the entry.
       */
      data_ptr = TBLPTR;
      
      /* now assign the source address to the table pointer */
      TBLPTR = prom;

      /* do the copy loop */
      _asm
        // determine if we have any more bytes to copy
        movlb curr_byte
        movf curr_byte, 1, 1
copy_loop:
        bnz 2 // copy_one_byte
        movf curr_byte + 1, 1, 1
        bz 7 // done_copying

copy_one_byte:
        tblrdpostinc
        movf TABLAT, 0, 0
        movwf POSTINC0, 0

        // decrement byte counter
        decf curr_byte, 1, 1
        bc -8 // copy_loop
        decf curr_byte + 1, 1, 1
        bra -7 // copy_one_byte

done_copying:

      _endasm
      /* restore the table pointer for the next entry */
      TBLPTR = data_ptr;
      /* next entry... */
      curr_entry--;
      goto test;
done:
;
}

Note : This code is located at svn://pericles/EDC/simplemotor/c018i-userapp.c

basic workflow of the SR06 ROS EtherCAT driver

pr2_etherCAT.svg

The pr2_etherCAT node has a controlLoop() which is an infinite loop that runs EthercatHardware::update()

EthercatHardware::update() will do two things :

The loop frequency is 1 kHz.

Basically, the node sends a command to each slave, and then receive the state of each slave, in a loop.

SR06::packCommand()

This function is called once per millisecond by the controlLoop of pr2_etherCAT node on each etherCAT slave. SR06::packCommand() is the function that fills the buffers to be sent via EtherCAT to the SR EDC EtherCAT hand. Several commands can be packed, the sr06 driver for example is sending two commands. See the SR06::packCommand() for more information about this function.

In this case the command_size_ attribute should be the sum of the size of the two commands.

Bootloading protocole

The bootloading is done through CAN, so the bootloader (which is on the PIC18F simplemotor board) waits for CAN commands in order to execute them to erase flash, write flash and so on.

But the bootloading is asked and managed by ROS (using the ROS service call), ROS in theory does not send any CAN message, ROS only interacts with the PIC32 through the EtherCAT bus.

So in order to do the bootloading, ROS sets the first member of the first structure of the "command" EtherCAT Mailbox (which is named EDC_command) to be : EDC_COMMAND_CAN_TEST_MODE.

Doing this will put the PIC32 in "CAN_TEST_MODE".

So that afterward ROS can put can messages in the second structure of the "command" EtherCAT Mailbox, the PIC32 will read these can messages and send them over the CAN bus. Moreover any CAN message received by the PIC32 will be forwarded back to ROS through the EtherCAT "status" Mailbox. So that the ROS flashing service can check for ACK from the PIC18F during the bootloading process.

Eventually ROS is able to send CAN message, putting the PIC32 is "CAN_TEST_MODE" and then execute the bootloading sequence on a SimpleMotor PCB.

The bootloading protocol is described in the following subsection.

CAN SID bit mapping

A CAN SID is 11 bits-wide.

CAN bootloader commands

WRITE_FLASH_DATA_COMMAND

This command :

It triggers this code in the PIC18F bootloader :

/*  This function does the actual writting in program memory (which is flash)
  Writting the flash has to be done by 32 bytes blocks. But we can only transport
  8 bytes of data in a CAN message, so we do the block writting in 4 CAN commands.
  So this command writes 8 bytes, and is called several times (4 times in theory)
  Each time it is called the "position" variable gets added 8.
  When position gets equal to 32, it means we have buffered a block and we can start
  the writting procedure.
 */
static void write_flash_data(void)
{
   int8 i;
    
    for (i = 0 ; i < 8 ; ++i) // Put 8 more bytes in the buffer
    {   
        TABLAT = CanMsgR.d.byte[i];
        _asm
            TBLWTPOSTINC
        _endasm
    }   

    position += 8; // We just buffered 8 more bytes

    if (position == 32) 
    { // We have buffured a 32 bytes block, we can start the writting procedure
        _asm
            TBLRDPOSTDEC
        _endasm // We do a TBLRDPOSTDEC in order for the TBLPTR addressing register to stay in the range of the 32 bytes
                // block we are writting, this is necessary because there has been one extra unneeded TBLWTPOSTINC during
                // the previous loop. If we don't do that, the block will be written over the next block, so 32 bytes after
                // the address provided by the previous WRITE_FLASH_ADDRESS command.
        EECON1 |= ((1 << EEPGD) | (1 << WREN)); // point to Flash program memory & enable write to memory
        EECON1 &= ~(1 << CFGS); // access Flash program memory
        INTCON &= ~(1 << GIE); // disable interrupts
        EECON2 = 0x55; //magic enable
        EECON2 = 0xaa;
        EECON1 |= (1 << WR); // starts the actual writting (CPU stall)
        INTCON |= (1 << GIE); // re-enable interrupts
        EECON1 &= ~(1 << WREN); // disable write to memory
        position = 0; // reset the position to 0, we just finished a 32 bytes block
    }   
} 

READ_FLASH_COMMAND

This command :

It triggers this code in the PIC18F bootloader :

/*  This function reads the program memory (which is flash)
  It reads 8 bytes at the address present in the incoming CAN
  command and sends it back through CAN too.
 */
static void read_flash(void)
{
    overlay int8 i = 0;
    TBLPTRL = CanMsgR.d.byte[0];
    TBLPTRH = CanMsgR.d.byte[1];
    TBLPTRU = CanMsgR.d.byte[2];
    while (i < 8)
    {
        _asm
            TBLRDPOSTINC
        _endasm
        CanMsgT.d.byte[i++] = TABLAT;
    }
    CanMsgT.messageID = CanMsgR.messageID | 0x10; // We set the "ACK" bit in the SID
    CanMsgT.length = 8;
    sendCanMsg(); // We send it
}

ERASE_FLASH_COMMAND

This command :

It triggers this code in the PIC18F bootloader :

/* This function erases the program memory (which is flash)
  It only erases the user application
  So it starts erasing after the bootloader and stops before the debugger code
 */
static void erase_flash(void)
{
    overlay int16 i = 0;

    i = 0x04c0; // user application start address
    // 0x7dc0 is the start of debugger code
    while (i < 0x7dc0) // and we don't wanna erase debugger code
    {
        TBLPTR  = i;
        TBLPTR &= 0xFFFFE0;

        EECON1 |=128;                                   // point to Flash program memory
        EECON1 &= ~64;                                  // access Flash program memory
        EECON1 |= 4;                                    // enable write to memory
        EECON1 |= 16;                                   // enable Row Erase operation
        INTCON &= ~128;                                 // disable interrupts
        EECON2 = 0x55;
        EECON2 = 0xaa;
        EECON1 |= 2;                                    // start erase (CPU stall)

        while (EECON1 & 2);                           // Wait for stuff being written

        INTCON |= 128;
        i += 64;
    }  

}

RESET_COMMAND

This command :

It triggers this code in the PIC18F bootloader :

write_eeprom();
acknowledge_packet();
_asm
  goto 0x0000 // This jumps to the Reset vector
_endasm

With write_eeprom() being :

/* This function writes to the EEPROM memory
  The only goal of this function is to write something different than 0xFF
  at the last memory location in EEPROM. This happens right after a flashing
  operation has been successfull, to make sure that at the following reboot of the
  PIC18F, it will boot directly to the user application and not stay in bootloader mode.

  This function writes 0x42 to the last byte (address 0xFF).
 */
static void write_eeprom(void)
{
    EEADR = 0xff; // EEPROM address to write to 
    EEDATA = 0x42; // Something != than 0xFF it's the data to be written
    EECON1 &= ~( (1 << EEPGD) | (1 << CFGS) ); // we select EEPROM memory
    EECON1 |= (1 << WREN); // enable write to memory
    INTCON &= ~(1 << GIE); // disable interrupts

    EECON2 = 0x55; // magic enable
    EECON2 = 0xaa;
    EECON1 |= (1 << WR); // do the write

    while (EECON1 & (1 << WR)); // wait for the write to finish

    INTCON |= (1 << GIE); // enable back interrupts
    EECON1 &= ~(1 << WREN); // disable write to memory

}

READ_VERSION_COMMAND

This command :

It triggers this code in the PIC18F bootloader :

CanMsgT.messageID = CanMsgR.messageID | 0x10;
CanMsgT.d.byte[0] = 0x40;
CanMsgT.d.byte[1] = 0x41;
CanMsgT.d.byte[2] = 0x42;
CanMsgT.d.byte[3] = 0x43;
CanMsgT.length = 4;
sendCanMsg();

WRITE_FLASH_ADDRESS_COMMAND

This command :

It triggers this code in the PIC18F bootloader :

/* This is setting up the flash addressing registers to some address received by a
  CAN command. This sets everything up for future writes.
 */
static void write_flash_address(void)
{
    position = 0; // resets the position to 0, we are starting to write a 32 bytes block
    TBLPTRU = CanMsgR.d.byte[2];
    TBLPTRH = CanMsgR.d.byte[1];
    TBLPTRL = CanMsgR.d.byte[0];
}

MAGIC_PACKET

This command :

It triggers this code in the PIC18F bootloader :

_asm
    goto 0x0000
_endasm

Note : if this packet is received by the SimpleMotor code (and *NOT* by the bootloader code), with the first two bits of the SID equal to "10" : Then before rebooting the PIC18F will erase the last byte of EEPROM, setting it to 0xFF. This is to force the bootloader to stay in bootloader mode upon reboot and not start directly the user application.

How ROS interacts with the PIC32

ROS commands sent to the PIC32

ROS sends commands through the EtherCAT protocol using one Mailbox and one FMMU (which are setup in SR06::construct()).

The data are sent by the SR06::packCommand() function.

The commands consist in 2 structures.

The first one :

typedef struct
{
    EDC_COMMAND         EDC_command;
    int16s  motor_torque_demand[20];
} __attribute__((packed)) ETHERCAT_DATA_STRUCTURE_0200_PALM_EDC_INCOMING;

the EDC_COMMAND type being :

typedef enum
{
    EDC_COMMAND_INVALID             = 0,
    EDC_COMMAND_SENSOR_DATA,
    EDC_COMMAND_SENSOR_CHANNEL_NUMBERS,
    EDC_COMMAND_SENSOR_ADC_CHANNEL_CS,
    EDC_COMMAND_TEST_RESULTS,
    EDC_COMMAND_CAN_TEST_MODE
}EDC_COMMAND;

This structure contains the torque demand that ROS sends to the PIC32 (which are afterward being forwarded through CAN by the PIC32 to the SimpleMotors). The first member of the structure describes the operating mode ROS wants the PIC32 to be in.

In this mode the PIC32 behaviour is the one described in the Palm_EDC_Specification.odt

Which means it sends the data request messages to the motors, reads ADC values (through SPI) sends torque demands to motors, sends back to ROS the Joints data and the motor data.

The second structure in the command is :

typedef struct
{
        int8u   can_bus;
        int8u   message_length;
        int16u  message_id;
        int8u   message_data[8];
} __attribute__((packed)) ETHERCAT_CAN_BRIDGE_DATA;

This structure is filled with CAN messages to be sent by the PIC32 on the CAN bus. It is only usefull in CAN_TEST_MODE. The content of this structure will be ignored by the PIC32 if not in CAN_TEST_MODE.

ROS status sent by the PIC32, received by ROS

ROS receives data through the EtherCAT protocol using one Mailbox and one FMMU (which are setup in SR06::construct()).

The data are sent by the PIC32 and received by SR06::unpackState() function.

Those data are called "status" in ROS.

The status consist in 2 structures.

The first one :

typedef struct
{
    EDC_COMMAND         EDC_command;
    int16s              FFJ1, FFJ2, FFJ3, FFJ4;
    int16s              MFJ1, MFJ2, MFJ3, MFJ4;
    int16s              RFJ1, RFJ2, RFJ3, RFJ4;
    int16s              LFJ1, LFJ2, LFJ3, LFJ4;
    int16s              LFJ5, THJ1, THJ2, THJ3;
    int16s              THJ4, THJ5, WRJ1, WRJ2;

    int16s              ACCX, ACCY, ACCZ, GYRX;
    int16s              GYRY, GYRZ, AN_0, AN_1;

    int16s              AN_2, AN_3, nothing1, nothing2;

    MOTOR_DATA_OUT      motor[20];

    TACTILE_SENSOR_OUT  tactile[5];
}__attribute__((packed)) ETHERCAT_DATA_STRUCTURE_0200_PALM_EDC_OUTGOING;

With EDC_COMMAND being :

typedef enum
{
    EDC_COMMAND_INVALID             = 0,
    EDC_COMMAND_SENSOR_DATA,
    EDC_COMMAND_SENSOR_CHANNEL_NUMBERS,
    EDC_COMMAND_SENSOR_ADC_CHANNEL_CS,
    EDC_COMMAND_TEST_RESULTS,
    EDC_COMMAND_CAN_TEST_MODE
}EDC_COMMAND;

MOTOR_DATA_OUT structure is defined by :

typedef struct
{
    int16s              torque;
    int16u              SG_L;
    int16u              SG_R;
    int16s              temperature;
    int16s              current;
    int16u              flags;
}MOTOR_DATA_OUT;

The second status :

the structure is the same as the one in the command :

typedef struct
{
        int8u   can_bus;
        int8u   message_length;
        int16u  message_id;
        int8u   message_data[8];
} __attribute__((packed)) ETHERCAT_CAN_BRIDGE_DATA;

TODO & Improvements

What's next to be done ?

For the moment there is the possibility to put the PIC32 in "CAN_TEST_MODE", so that it will act as a CAN bridge between ROS and the PIC18. Which means the PIC32 will read a structure in the "command" Mailbox (coming from ROS) containing a CAN message to be sent. It will send the CAN message over the CAN bus. And every CAN message received by the PIC32 will be sent back to ROS through a structure in the "status" Mailbox (from the PIC32 to ROS).

This is implemented in the PIC32 and has been tested (putting some test code in SR06::packCommand() and SR06::unpackState() which has been removed afterward) and works.

But there is no code at the moment in the last revision of SR06 ROS driver that gives a way to do that (telling the PIC32 to send a crafted CAN message)

What would be great to implement is a ROS service that would allow sending such a crafted CAN message.

doing something like

rosservice call "SendCanMessage" SID Length 8-bytes-long-content-in-hex

So that one could do tests running the service manually (typing the above command in a shell) or doing automated test running a special test node that would call this service one or several times.

For the moment there is the possibility to put the PIC32 in "SENSOR_CHANNEL_NUMBERS" mode, so that the PIC32 won't put the sensor (joints) data inside the structure which is sent back to ROS through the "status" Mailbox, but instead it would put the ADC number and channel number.

For example :

ROS_INFO("LFJ5 : 0x%04X", tbuffer->LFJ5);

would print "LFJ5 : 0x0605" while in that "SENSOR_CHANNEL_NUMBERS" mode, because LFJ5's data comes from Channel 06 of ADC 05.

This is a "test" feature, it has been tested putting some code in SR06::packCommand() and SR06::unpackState() which has been removed afterward since it's not "normal functionning".

So it would be great to implement a service to enter that mode and test if the data received are correct.

Doing something like

rosservice call "TestADCChannelNumbers"

The protocol described in svn://thoth/trunk/Projects/Palm_EDC_Development/Docs/Specification/Palm_EDC_Specification.odt for the communication between PIC32 and PIC18F is quite messy and has been re-thought differently, using more efficiently the SID and in a more "standardized" way.

This new SID bit mapping is actually the one used by the PIC18F CAN bootloader, and which is described in the "CAN SID bit mapping" subsection of the "Bootloading protocole" section.

It would be great to change the PIC32 and SimpleMotor code to reflect this kind of new SID bit mapping. So that all the SIDs in all the CAN protocols used through the robot code would be homogeneous.

  1. The two first bits can stay as "input"/"output" bits, the following 4 bits can stay as "motor_id" bits, the bit 0x10 can stay as an ACK bit if ACK is needed, and the last 4 bits can contain the "data requested" in case of a "request data message" (as explained in the Palm_EDC_Specification.odt).
  2. That would save using 2 bytes in the can datas for the "which_data" field, just putting this in the last 4 bits of the SID.
  3. If there is too much data types to ask, we could use the last 5 bits of the SID (so one extra bit), since there is no ACK in this protocol anyway.


sr_edc_ethercat_drivers
Author(s): Ugo Cupcic, Yann Sionneau, Toni Oliver
autogenerated on Thu Aug 27 2015 15:16:54