Moving motors is the heart of the EV3. A robot needs the ability to move, therefore you need to know, how it's done. Take a look at part 4.9 in the document EV3 Firmware Developer Kit to get a first impression about the operations that correspond to the motors. Nearly all of them start with the same two arguments:
- LAYER: If you combine more than one EV3 bricks to act as a
single machine, you declare one of them to be the master and
up to 3 additional bricks to be the masters slaves. Then you
send your operations to the master brick and the master
sends it to the slaves. This is the meaning of LAYER. Layer
0x|00|
is the master, layers0x|01|
,0x|02|
and0x|03|
are its optional slaves. In the standard case, where no slaves exist, we will setLAYER = 0x|00|
. Our coding with direct commands allows to connect multipleEV3
devices. Therefore I see no need for different values of argument LAYER. - NO or NOS: The motor ports are alphabetically identified
with A, B, C and D. At the level of direct commands you name
them by numbers,
A = 0x|01|
,B = 0x|02|
,C = 0x|04|
andD = 0x|08|
. If you write these numbers as binaries,A = 0b 0000 0001
,B = 0b 0000 0010
,C = 0b 0000 0100
andC = 0b 0000 1000
, you see, that the last half byte can be interpreted as a series of four flags. You can combine these flags. If you address an operation to the motors at ports A and C, you setNOS = 0x|05|
resp.0b 0000 0101
. If an arguments name is NO, you can address only a single motor port. NOS says, that you can address one or multiple ports.
Start and stop motors
Plug in some motors to the ports of your choice, then start them with full power. This is done by the following direct command:
----------------------------------------------
\ len \ cnt \ty\ hd \op\la\no\power\op\la\no\
----------------------------------------------
0x|0D:00|2A:00|80|00:00|A4|00|0F|81:64|A6|00|0F|
----------------------------------------------
\ 13 \ 42 \no\ 0,0 \O \0 \a \ 100 \O \0 \a \
\ \ \ \ \u \ \l \ \u \ \l \
\ \ \ \ \t \ \l \ \t \ \l \
\ \ \ \ \p \ \ \ \p \ \ \
\ \ \ \ \u \ \ \ \u \ \ \
\ \ \ \ \t \ \ \ \t \ \ \
\ \ \ \ \_ \ \ \ \_ \ \ \
\ \ \ \ \P \ \ \ \S \ \ \
\ \ \ \ \o \ \ \ \t \ \ \
\ \ \ \ \w \ \ \ \a \ \ \
\ \ \ \ \e \ \ \ \r \ \ \
\ \ \ \ \r \ \ \ \t \ \ \
----------------------------------------------
After some time, you, your parents, siblings or children become nerved of the sound of full powered motors. If you are a peace loving character, you should send the following direct command:
----------------------------------
\ len \ cnt \ty\ hd \op\la\no\br\
----------------------------------
0x|09:00|2A:00|00|00:00|A3|00|0F|00|
----------------------------------
\ 9 \ 42 \re\ 0,0 \O \0 \a \no\
\ \ \ \ \u \ \l \ \
\ \ \ \ \t \ \l \ \
\ \ \ \ \p \ \ \ \
\ \ \ \ \u \ \ \ \
\ \ \ \ \t \ \ \ \
\ \ \ \ \_ \ \ \ \
\ \ \ \ \S \ \ \ \
\ \ \ \ \t \ \ \ \
\ \ \ \ \o \ \ \ \
\ \ \ \ \p \ \ \ \
----------------------------------
We have seen three new operations:
opOutput_Power = 0x|A4|
with the arguments:- LAYER
- NOS
- POWER: specifies output power [-100 – 100 %] (negative values mean opposite direction).
opOutput_Start = 0x|A6|
with the arguments:- LAYER
- NOS
opOutput_Stop = 0x|A3|
with the arguments:- LAYER
- NOS
- BRAKE: Specify break level [0: Float, 1: Break]. This is not a passive brake, where the motor is fixed at its position. It is an active one, where the motor tries to move back to its original position and this costs energy!
opOutput_Polarity = 0x|A7|
with the arguments:- LAYER
- NOS
- POLARITY: specifies polarity [-1, 0, 1]
- -1: Motor will run backward
- 0: Motor will run opposite direction
- +1: Motor will run forward
Defining speed instead of power often is the better choice. Luckily the LEGO motors are regulated ones and we can easily define constant speed for motor movement with the following operation:
opOutput_Speed = 0x|A5|
with the arguments:- LAYER
- NOS
- SPEED: specifies output speed [-100 – 100 %] (negative values mean opposite direction).
Please construct a vehicle, that is powered by two motors, one for the left side, the other for the right one. Well designed examples are:
- http://robotsquare.com/2015/10/06/explor3r-building-instructions
- http://robotsquare.com/wp-content/uploads/2013/10/45544_educator.pdf
- http://www.lego.com/en-gb/mindstorms/build-a-robot/track3r
- http://www.lego.com/en-gb/mindstorms/build-a-robot/robodoz3r
-------------------------------------------
\ len \ cnt \ty\ hd \op\la\no\sp\op\la\no\
-------------------------------------------
0x|0C:00|2A:00|80|00:00|A5|00|09|3D|A6|00|09|
-------------------------------------------
\ 12 \ 42 \no\ 0,0 \O \0 \A \-3\O \0 \A \
\ \ \ \ \u \ \+ \ \u \ \+ \
\ \ \ \ \t \ \D \ \t \ \D \
\ \ \ \ \p \ \ \ \p \ \ \
\ \ \ \ \u \ \ \ \u \ \ \
\ \ \ \ \t \ \ \ \t \ \ \
\ \ \ \ \_ \ \ \ \_ \ \ \
\ \ \ \ \S \ \ \ \S \ \ \
\ \ \ \ \p \ \ \ \t \ \ \
\ \ \ \ \e \ \ \ \a \ \ \
\ \ \ \ \e \ \ \ \r \ \ \
\ \ \ \ \d \ \ \ \t \ \ \
-------------------------------------------
Hopefully your vehicle moves straight with low velocity. Use the direct command above (opOutput_Stop
)
to stop it.
Remote controlled vehicles
Now it's time to code a real application. Impress your friends or children
with a remote controlled EV3 vehicle. We start with a simple remote control and use the
keyboards arrow-keys to change speed and direction. First add the new operations
to your class EV3
, then write your remote-control-program. As usual I have done it in
python3:
#!/usr/bin/env python3
import curses
import ev3_dc as ev3
def move(speed: int, turn: int) -> None:
global myEV3, stdscr
stdscr.addstr(5, 0, 'speed: {}, turn: {} '.format(speed, turn))
if turn > 0:
speed_right = speed
speed_left = round(speed * (1 - turn / 100))
else:
speed_right = round(speed * (1 + turn / 100))
speed_left = speed
ops = b''.join([
ev3.opOutput_Speed,
ev3.LCX(0), # LAYER
ev3.LCX(ev3.PORT_A), # NOS
ev3.LCX(speed_right), # SPEED
ev3.opOutput_Speed,
ev3.LCX(0), # LAYER
ev3.LCX(ev3.PORT_D), # NOS
ev3.LCX(speed_left), # SPEED
ev3.opOutput_Start,
ev3.LCX(0), # LAYER
ev3.LCX(ev3.PORT_A + ev3.PORT_D) # NOS
])
myEV3.send_direct_cmd(ops)
def stop() -> None:
global myEV3, stdscr
stdscr.addstr(5, 0, 'vehicle stopped ')
ops = b''.join([
ev3.opOutput_Stop,
ev3.LCX(0), # LAYER
ev3.LCX(ev3.PORT_A + ev3.PORT_D), # NOS
ev3.LCX(0) # BRAKE
])
myEV3.send_direct_cmd(ops)
def react(c):
global speed, turn
if c in [ord('q'), 27, ord('p')]:
stop()
return
elif c == curses.KEY_LEFT:
turn += 5
turn = min(turn, 200)
elif c == curses.KEY_RIGHT:
turn -= 5
turn = max(turn, -200)
elif c == curses.KEY_UP:
speed += 5
speed = min(speed, 100)
elif c == curses.KEY_DOWN:
speed -= 5
speed = max(speed, -100)
move(speed, turn)
def main(window) -> None:
global stdscr
stdscr = window
stdscr.clear() # print introduction
stdscr.refresh()
stdscr.addstr(0, 0, 'Use Arrows to navigate your EV3-vehicle')
stdscr.addstr(1, 0, 'Pause your vehicle with key <p>')
stdscr.addstr(2, 0, 'Terminate with key <q>')
while True:
c = stdscr.getch()
if c in [ord('q'), 27]:
react(c)
break
elif c in [ord('p'),
curses.KEY_RIGHT, curses.KEY_LEFT, curses.KEY_UP, curses.KEY_DOWN]:
react(c)
speed = 0
turn = 0
myEV3 = ev3.EV3(protocol=ev3.BLUETOOTH, host='00:16:53:42:2B:99')
stdscr = None
# ops = opOutput_Polarity + b'\x00' + LCX(PORT_A + PORT_D) + LCX(-1)
# myEV3.send_direct_cmd(ops)
curses.wrapper(main)
Some remarks:
- I used the module
curses
to realize an event handler for keyboard events. - Method
wrapper
takes control over the resources keyboard and terminal. It changes the behavior of the terminal, creates awindow
object and calls functionmain
. - Function
main
writes some text to the terminal and then waits for key events (methodgetch
). - If one of the relevant keys is hit, function
react
is called, keys<q>
or<Ctrl-c>
break the loop. - Variable
speed
defines the speed of the faster wheel. - Variable
turn
defines the direction. Value0
means straight on, positive values result in left turns, negative values in right turns. Large absolute values ofturn
mean small radius of the turn. Maximum value+200
and minimum value-200
mean circling on place. In the next lesson, we will come back to this definition of driving a turn. - I changed
speed
andturn
in steps of 5. This seems to be a good compromize beween precision and speed of reaction. - Maybe, the construction of your vehicle needs the change of polarity for both wheels. This is done by the commented code.
- The right wheel is connected to port A, the left one to port D.
- For small velocities, the precision of the turns radius becomes worse, because of the rounding to integer values.
Interruption as a design concept
Last lesson, we compared interruption with an impatient and badly behaving character. This lesson shows, that exactly this behaviour can be a practicable design concept.
The remote control program sends unlimited commands, which would move the motors forever, if not interrupted by the next one. A human mind, that controls the driving, wants her corrections immediately take place. It's absolutely correct, that the newer command interrupts the older one.
Another positive aspect of this design concept is the short
execution time of the direct commands. They are not time
consuming. The movement of the motors lasts long, but the
execution of the direct command is finished, when the motors got
their new parameters. Control is back immediately and the
program is free, to do the next task. The
resource EV3
device isn't blocked, it's available
for the next direct command.
You have seen, how the interruption process of the remote
control is coded. The central part is a loop, that asks for key
events. If a relevant key event occurs, a function is called,
which changes the parameters of the movement and sends a direct
command to the EV3
device.
When we played the triad in c'' (last lesson), we avoided interruption. We had a fixed schedule of all the tones to play. Our opinion was, that every interruption disturbs the plan and needs to be prevented. When drawing the triangle, we already discussed two alternatives for the timing, by the direct command or by the local program. Playing a triad can also be done with all the scheduling in the local program and using interruption. This is an alternative to get the correct timing:
#!/usr/bin/env python3
import ev3_dc as ev3
import time
my_ev3 = ev3.EV3(protocol=ev3.BLUETOOTH, host='00:16:53:42:2B:99')
ops = b''.join([
ev3.opUI_Write,
ev3.LED,
ev3.LED_RED,
ev3.opSound,
ev3.TONE,
ev3.LCX(1),
ev3.LCX(262),
ev3.LCX(0)
])
my_ev3.send_direct_cmd(ops)
time.sleep(0.5)
ops = b''.join([
ev3.opUI_Write,
ev3.LED,
ev3.LED_GREEN,
ev3.opSound,
ev3.TONE,
ev3.LCX(1),
ev3.LCX(330),
ev3.LCX(0)
])
my_ev3.send_direct_cmd(ops)
time.sleep(0.5)
ops = b''.join([
ev3.opUI_Write,
ev3.LED,
ev3.LED_RED,
ev3.opSound,
ev3.TONE,
ev3.LCX(1),
ev3.LCX(392),
ev3.LCX(0)
])
my_ev3.send_direct_cmd(ops)
time.sleep(0.5)
ops = b''.join([
ev3.opUI_Write,
ev3.LED,
ev3.LED_RED_FLASH,
ev3.opSound,
ev3.TONE,
ev3.LCX(1),
ev3.LCX(523),
ev3.LCX(0)
])
my_ev3.send_direct_cmd(ops)
time.sleep(2)
ops = b''.join([
ev3.opUI_Write,
ev3.LED,
ev3.LED_GREEN,
ev3.opSound,
ev3.BREAK
])
my_ev3.send_direct_cmd(ops)
This version also realizes a fixed schedule, but the timing
happens in the program and not on the EV3
device
(resp. in the direct command). Again this has the advantage, not
to block the EV3
device. As long, as we execute
only one task at a time, blocking does not matter. If we try to
combine f.i. sound and driving in independent and parallel
executed tasks, local timing and interruption are a must.
If we would set sync_mode == SYNC
in the program above, the data traffic would grow but we
wouldn't hear any difference. It's worth to reflect, that this
program runs synchronously because none of the direct
commands is time consuming. sync_mode == ASYNC
or sync_mode == STD
are designed for asynchronous
execution, but we also can code synchronous execution with
these settings. Asynchronous execution only happens when the
direct command is time consuming (resp. the timing is done by
the direct command) and the local program does not wait until
the direct command ends. Interruption helps to avoid time consuming
direct commands and is synchronous execution.
Subclassing EV3
Up to now, we composed the operations directly
in our programs. This cries for encapsulation and abstraction.
I think of specialized classes, one for vehicles with two drived wheels,
one for tones and music and so on.
The design should allow to use them parallel.
My solution is a number of subclasses of class EV3
, which
all communicate with the same EV3
device. This says, they
have to share
resources. To realize this, I have changed the constructor of
class EV3
:
| __init__(self, protocol:str=None, host:str=None, ev3_obj=None)
| Establish a connection to a LEGO EV3 device
|
| Keyword Arguments (either protocol and host or ev3_obj):
| protocol: None, 'Bluetooth', 'Usb' or 'Wifi'
| host: None or mac-address of the LEGO EV3 (f.i. '00:16:53:42:2B:99')
| ev3_obj: None or an existing EV3 object (its connections will be used)
The code:
class EV3:
def __init__(self, protocol: str=None, host: str=None, ev3_obj=None):
assert ev3_obj or protocol, \
'Either protocol or ev3_obj needs to be given'
if ev3_obj:
assert isinstance(ev3_obj, EV3), \
'ev3_obj needs to be instance of EV3'
self._protocol = ev3_obj._protocol
self._device = ev3_obj._device
self._socket = ev3_obj._socket
elif protocol:
assert protocol in [BLUETOOTH, WIFI, USB], \
'Protocol ' + protocol + 'is not valid'
self._protocol = None
self._device = None
self._socket = None
if protocol == BLUETOOTH:
assert host, 'Protocol ' + protocol + 'needs host-id'
self._connect_bluetooth(host)
elif protocol == WIFI:
self._connect_wifi()
elif protocol == USB:
self._connect_usb()
self._verbosity = 0
self._sync_mode = STD
self._msg_cnt = 41
A few annotations:
- There are two ways to create a new instance of
class
EV3
:- As before, call the constructor with the
arguments
protocol
andhost
for a new connection. - The alternative is calling it with an
existing
EV3
object as argumentev3_obj
, from which the connections are used.
- As before, call the constructor with the
arguments
- This is not limited to connections. For future extensions, we can share any of the resources.
- Every class has its
own
_verbosity
and_sync_mode
. This is o.k., but we make the message counter_msg_cnt
a class attribute.class EV3: _msg_cnt = 41
TwoWheelVehicle
,
here its constructor:
class TwoWheelVehicle(ev3.EV3):
def __init__(
self,
protocol: str=None,
host: str=None,
ev3_obj: ev3.EV3=None
):
super().__init__(protocol=protocol, host=host, ev3_obj=ev3_obj)
self._polarity = 1
self._port_left = ev3.PORT_D
self._port_right = ev3.PORT_A
I added two methods:
def move(self, speed: int, turn: int) -> None:
assert self._sync_mode != ev3.SYNC, "no unlimited operations allowed in sync_mode SYNC"
assert isinstance(speed, int), "speed needs to be an integer value"
assert -100 <= speed and speed <= 100, "speed needs to be in range [-100 - 100]"
assert isinstance(turn, int), "turn needs to be an integer value"
assert -200 <= turn and turn <= 200, "turn needs to be in range [-200 - 200]"
if self._polarity == -1:
speed *= -1
if self._port_left < self._port_right:
turn *= -1
if turn > 0:
speed_right = speed
speed_left = round(speed * (1 - turn / 100))
else:
speed_right = round(speed * (1 + turn / 100))
speed_left = speed
ops = b''.join([
ev3.opOutput_Speed,
ev3.LCX(0), # LAYER
ev3.LCX(PORT_A), # NOS
ev3.LCX(speed_right), # SPEED
ev3.opOutput_Speed,
ev3.LCX(0), # LAYER
ev3.LCX(PORT_D), # NOS
ev3.LCX(speed_left), # SPEED
ev3.opOutput_Start,
ev3.LCX(0), # LAYER
ev3.LCX(PORT_A + PORT_D) # NOS
])
self.send_direct_cmd(ops)
and
def stop(self, brake: bool=False) -> None:
assert isinstance(brake, bool), "brake needs to be a boolean value"
if brake:
br = 1
else:
br = 0
ops_stop = b''.join([
ev3.opOutput_Stop,
ev3.LCX(0), # LAYER
ev3.LCX(self._port_left + self._port_right), # NOS
ev3.LCX(br) # BRAKE
])
self.send_direct_cmd(ops)
We need three properties:
@property
def polarity(self):
return self._polarity
@polarity.setter
def polarity(self, value: int):
assert isinstance(value, int), "polarity needs to be of type int"
assert value in [1, -1], "allowed polarity values are: -1 or 1"
self._polarity = value
@property
def port_right(self):
return self._port_right
@port_right.setter
def port_right(self, value: int):
assert isinstance(value, int), "port needs to be of type int"
assert value in [ev3.PORT_A, ev3.PORT_B, ev3.PORT_C, ev3.PORT_D], "value is not an allowed port"
self._port_right = value
@property
def port_left(self):
return self._port_left
@port_left.setter
def port_left(self, value: int):
assert isinstance(value, int), "port needs to be of type int"
assert value in [ev3.PORT_A, ev3.PORT_B, ev3.PORT_C, ev3.PORT_D], "value is not an allowed port"
self._port_left = value
That's it, we have a new class TwoWheelVehicle
with this API:
Help on module ev3_vehicle:
NAME
ev3_vehicle - EV3 vehicle
CLASSES
ev3.EV3(builtins.object)
TwoWheelVehicle
class TwoWheelVehicle(ev3.EV3)
| ev3.EV3 vehicle with two drived Wheels
|
| Method resolution order:
| TwoWheelVehicle
| ev3.EV3
| builtins.object
|
| Methods defined here:
|
| __init__(self, protocol:str=None, host:str=None, ev3_obj:ev3.EV3=None)
| Establish a connection to a LEGO EV3 device
|
| Keyword Arguments (either protocol and host or ev3_obj):
| protocol
| BLUETOOTH == 'Bluetooth'
| USB == 'Usb'
| WIFI == 'Wifi'
| host: mac-address of the LEGO EV3 (f.i. '00:16:53:42:2B:99')
| ev3_obj: an existing EV3 object (its connections will be used)
|
| move(self, speed:int, turn:int) -> None
| Start unlimited movement of the vehicle
|
| Arguments:
| speed: speed in percent [-100 - 100]
| > 0: forward
| < 0: backward
| turn: type of turn [-200 - 200]
| -200: circle right on place
| -100: turn right with unmoved right wheel
| 0 : straight
| 100: turn left with unmoved left wheel
| 200: circle left on place
|
| stop(self, brake:bool=False) -> None
| Stop movement of the vehicle
|
| Arguments:
| brake: flag if activating brake
|
| ----------------------------------------------------------------------
| Data descriptors defined here:
|
| polarity
| polarity of motor rotation (values: -1, 1, default: 1)
|
| port_left
| port of left wheel (default: PORT_D)
|
| port_right
| port of right wheel (default: PORT_A)
|
| ----------------------------------------------------------------------
| Methods inherited from ev3.EV3:
|
| __del__(self)
| closes the connection to the LEGO EV3
|
| send_direct_cmd(self, ops:bytes, local_mem:int=0, global_mem:int=0) -> bytes
| Send a direct command to the LEGO EV3
|
| Arguments:
| ops: holds netto data only (operations), the following fields are added:
| length: 2 bytes, little endian
| counter: 2 bytes, little endian
| type: 1 byte, DIRECT_COMMAND_REPLY or DIRECT_COMMAND_NO_REPLY
| header: 2 bytes, holds sizes of local and global memory
|
| Keyword Arguments:
| local_mem: size of the local memory
| global_mem: size of the global memory
|
| Returns:
| sync_mode is STD: reply (if global_mem > 0) or message counter
| sync_mode is ASYNC: message counter
| sync_mode is SYNC: reply of the LEGO EV3
|
| wait_for_reply(self, counter:bytes) -> bytes
| Ask the LEGO EV3 for a reply and wait until it is received
|
| Arguments:
| counter: is the message counter of the corresponding send_direct_cmd
|
| Returns:
| reply to the direct command
|
| ----------------------------------------------------------------------
| Data descriptors inherited from ev3.EV3:
|
| __dict__
| dictionary for instance variables (if defined)
|
| __weakref__
| list of weak references to the object (if defined)
|
| sync_mode
| sync mode (standard, asynchronous, synchronous)
|
| STD: Use DIRECT_COMMAND_REPLY if global_mem > 0,
| wait for reply if there is one.
| ASYNC: Use DIRECT_COMMAND_REPLY if global_mem > 0,
| never wait for reply (it's the task of the calling program).
| SYNC: Always use DIRECT_COMMAND_REPLY and wait for reply.
|
| The general idea is:
| ASYNC: Interruption or EV3 device queues direct commands,
| control directly comes back.
| SYNC: EV3 device is blocked until direct command is finished,
| control comes back, when direct command is finished.
| STD: NO_REPLY like ASYNC with interruption or EV3 queuing,
| REPLY like SYNC, synchronicity of program and EV3 device.
|
| verbosity
| level of verbosity (prints on stdout).
From now on, we will use class TwoWheelVehicle
,
when we move a vehicle with two drived wheels. Actually this was
more an exercise, than a real thing, but next lesson, we will
add functionality to this class. For the moment you may ask, if
effort and benefit really are balanced. But look, our program
has become shorter:
#!/usr/bin/env python3
import curses
import ev3_dc as ev3
def react(c):
global speed, turn, my_vehicle
if c in [ord('q'), 27, ord('p')]:
my_vehicle.stop()
return
elif c == curses.KEY_LEFT:
turn += 5
turn = min(turn, 200)
elif c == curses.KEY_RIGHT:
turn -= 5
turn = max(turn, -200)
elif c == curses.KEY_UP:
speed += 5
speed = min(speed, 100)
elif c == curses.KEY_DOWN:
speed -= 5
speed = max(speed, -100)
stdscr.addstr(5, 0, 'speed: {}, turn: {} '.format(speed, turn))
my_vehicle.move(speed, turn)
def main(window) -> None:
global stdscr
stdscr = window
stdscr.clear() # print introduction
stdscr.refresh()
stdscr.addstr(0, 0, 'Use Arrows to navigate your EV3-vehicle')
stdscr.addstr(1, 0, 'Pause your vehicle with key <p>')
stdscr.addstr(2, 0, 'Terminate with key <q>')
while True:
c = stdscr.getch()
if c in [ord('q'), 27]:
react(c)
break
elif c in [ord('p'),
curses.KEY_RIGHT, curses.KEY_LEFT, curses.KEY_UP, curses.KEY_DOWN]:
react(c)
speed = 0
turn = 0
my_vehicle = ev3.TwoWheelVehicle(protocol=ev3.BLUETOOTH, host='00:16:53:42:2B:99')
stdscr = None
curses.wrapper(main)
If you did download module ev3_vehicle.py
from
ev3-python3,
you need to call the constructor of TwoWheelVehicle
with two additional arguments radius_wheel
and tread
.
For the moment you may set them to any positive value.
Conclusion
Now we know, how to move motors and we got experience with two important design concepts, interruption and subclassing. Beside we coded something like a real app, a remote control for vehicles.
I hope, that your remote control works and made you a technic-star. We are at the end of this lesson. A glance at the document EV3 Firmware Developer Kit tells you, that there exists a number of additional operations to move motors. Most of them are for precise, synchronized and smooth movements. This will be the topic of our next lesson. I hope to see you again.
This comment has been removed by the author.
ReplyDelete