source: src/mlx/rpc.py@ 928:3b16c095d166

python3
Last change on this file since 928:3b16c095d166 was 928:3b16c095d166, checked in by István Váradi <ivaradi@…>, 6 years ago

Unicode handling changes (re #347).

File size: 25.9 KB
Line 
1from . import const
2from . import rpccommon
3
4from .common import MAVA_BASE_URL
5
6import jsonrpclib
7import hashlib
8import datetime
9import calendar
10import sys
11
12#---------------------------------------------------------------------------------------
13
14class RPCObject(object):
15 """Base class for objects read from RPC calls.
16
17 It is possible to construct it from a dictionary."""
18 def __init__(self, value, instructions = {}):
19 """Construct the object.
20
21 value is the dictionary returned by the call.
22
23 info is a mapping from names to 'instructions' on what to do with the
24 corresponding values. If the instruction is None, it will be ignored.
25 If the instruction is a function, the value will be passed to it and
26 the return value will be stored in the object.
27
28 For all other names, the value will be stored as the same-named
29 attribute."""
30 for (key, value) in value.items():
31 if key in instructions:
32 instruction = instructions[key]
33 if instruction is None:
34 continue
35
36 try:
37 value = instruction(value)
38 except:
39 print("Failed to convert value '%s' of attribute '%s':" % \
40 (value, key), file=sys.stderr)
41 import traceback
42 traceback.print_exc()
43 setattr(self, key, value)
44
45#---------------------------------------------------------------------------------------
46
47class Reply(RPCObject):
48 """The generic reply structure."""
49
50#---------------------------------------------------------------------------------------
51
52class ScheduledFlight(RPCObject):
53 """A scheduled flight in the time table."""
54 # The instructions for the construction
55 # Type: normal flight
56 TYPE_NORMAL = 0
57
58 # Type: VIP flight
59 TYPE_VIP = 1
60
61 _instructions = {
62 "id" : int,
63 "pairID": int,
64 "typeCode": lambda value: BookedFlight._decodeAircraftType(value),
65 "departureTime": lambda value: ScheduledFlight._decodeTime(value),
66 "arrivalTime": lambda value: ScheduledFlight._decodeTime(value),
67 "duration": lambda value: ScheduledFlight._decodeDuration(value),
68 "spec": int,
69 "validFrom": lambda value: ScheduledFlight._decodeDate(value),
70 "validTo": lambda value: ScheduledFlight._decodeDate(value),
71 "date": lambda value: ScheduledFlight._decodeDate(value)
72 }
73
74 @staticmethod
75 def _decodeTime(value):
76 """Decode the given value as a time value."""
77 return datetime.datetime.strptime(value, "%H:%M:%S").time()
78
79 @staticmethod
80 def _decodeDate(value):
81 """Decode the given value as a date value."""
82 if not value or value=="0000-00-00":
83 return const.defaultDate
84 else:
85 return datetime.datetime.strptime(value, "%Y-%m-%d").date()
86
87 @staticmethod
88 def _decodeDuration(value):
89 """Decode the given value as a duration.
90
91 A number of seconds will be returned."""
92 t = datetime.datetime.strptime(value, "%H:%M:%S")
93 return (t.hour*60 + t.minute) * 60 + t.second
94
95 def __init__(self, value):
96 """Construct the scheduled flight object from the given JSON value."""
97 super(ScheduledFlight, self).__init__(value,
98 ScheduledFlight._instructions)
99 self.aircraftType = self.typeCode
100 del self.typeCode
101
102 self.type = ScheduledFlight.TYPE_VIP if self.spec==1 \
103 else ScheduledFlight.TYPE_NORMAL
104 del self.spec
105
106 def compareBy(self, other, name):
107 """Compare this flight with the other one according to the given
108 attribute name."""
109 if name=="callsign":
110 try:
111 cs1 = int(self.callsign[2:])
112 cs2 = int(other.callsign[2:])
113 return 0 if cs1==cs2 else -1 if cs1<cs2 else 1
114 except:
115 return 0 if self.callsign==other.callsign \
116 else -1 if self.callsign<other.callsign else 1
117 else:
118 v1 = getattr(self, name)
119 v2 = getattr(other, name)
120 return 0 if v1==v2 else -1 if v1<v2 else 1
121
122 def __repr__(self):
123 return "ScheduledFlight<%d, %d, %s, %s (%s) - %s (%s) -> %d, %d>" % \
124 (self.id, self.pairID, BookedFlight.TYPE2TYPECODE[self.aircraftType],
125 self.departureICAO, str(self.departureTime),
126 self.arrivalICAO, str(self.arrivalTime),
127 self.duration, self.type)
128
129#---------------------------------------------------------------------------------------
130
131class ScheduledFlightPair(object):
132 """A pair of scheduled flights.
133
134 Occasionally, one of the flights may be missing."""
135 @staticmethod
136 def scheduledFlights2Pairs(scheduledFlights, date):
137 """Convert the given list of scheduled flights into a list of flight
138 pairs."""
139 weekday = str(date.weekday()+1)
140
141 flights = {}
142 weekdayFlights = {}
143 for flight in scheduledFlights:
144 flights[flight.id] = flight
145 if (flight.type==ScheduledFlight.TYPE_NORMAL and
146 flight.arrivalICAO!="LHBP" and weekday in flight.days and
147 flight.validFrom<=date and flight.validTo>=date) or \
148 flight.type==ScheduledFlight.TYPE_VIP:
149 weekdayFlights[flight.id] = flight
150
151 flightPairs = []
152
153 while weekdayFlights:
154 (id, flight) = weekdayFlights.popitem()
155 if flight.type==ScheduledFlight.TYPE_NORMAL:
156 pairID = flight.pairID
157 if pairID in flights:
158 pairFlight = flights[pairID]
159 if flight.departureICAO=="LHBP" or \
160 (pairFlight.departureICAO!="LHBP" and
161 flight.callsign<pairFlight.callsign):
162 flightPairs.append(ScheduledFlightPair(flight, pairFlight))
163 else:
164 flightPairs.append(ScheduledFlightPair(pairFlight, flight))
165 del flights[pairID]
166 if pairID in weekdayFlights:
167 del weekdayFlights[pairID]
168 elif flight.type==ScheduledFlight.TYPE_VIP:
169 flightPairs.append(ScheduledFlightPair(flight))
170
171 flightPairs.sort(key = lambda pair: pair.flight0.date)
172
173 return flightPairs
174
175 def __init__(self, flight0, flight1 = None):
176 """Construct the pair with the given flights."""
177 self.flight0 = flight0
178 self.flight1 = flight1
179
180 def compareBy(self, other, name):
181 """Compare this flight pair with the other one according to the given
182 attribute name, considering the first flights."""
183 return self.flight0.compareBy(other.flight0, name)
184
185 def __repr__(self):
186 return "ScheduledFlightPair<%s, %s, %s>" % \
187 (self.flight0.callsign, self.flight0.departureICAO,
188 self.flight0.arrivalICAO)
189
190#---------------------------------------------------------------------------------------
191
192class BookedFlight(RPCObject):
193 """A booked flight."""
194 # FIXME: copied from web.BookedFlight
195 TYPECODE2TYPE = { "736" : const.AIRCRAFT_B736,
196 "73G" : const.AIRCRAFT_B737,
197 "738" : const.AIRCRAFT_B738,
198 "73H" : const.AIRCRAFT_B738C,
199 "732" : const.AIRCRAFT_B732,
200 "733" : const.AIRCRAFT_B733,
201 "734" : const.AIRCRAFT_B734,
202 "735" : const.AIRCRAFT_B735,
203 "DH4" : const.AIRCRAFT_DH8D,
204 "762" : const.AIRCRAFT_B762,
205 "763" : const.AIRCRAFT_B763,
206 "CR2" : const.AIRCRAFT_CRJ2,
207 "F70" : const.AIRCRAFT_F70,
208 "LI2" : const.AIRCRAFT_DC3,
209 "TU3" : const.AIRCRAFT_T134,
210 "TU5" : const.AIRCRAFT_T154,
211 "YK4" : const.AIRCRAFT_YK40,
212 "146" : const.AIRCRAFT_B462 }
213
214 # FIXME: copied from web.BookedFlight
215 TYPE2TYPECODE = { const.AIRCRAFT_B736 : "736",
216 const.AIRCRAFT_B737 : "73G",
217 const.AIRCRAFT_B738 : "738",
218 const.AIRCRAFT_B738C : "73H",
219 const.AIRCRAFT_B732 : "732",
220 const.AIRCRAFT_B733 : "733",
221 const.AIRCRAFT_B734 : "734",
222 const.AIRCRAFT_B735 : "735",
223 const.AIRCRAFT_DH8D : "DH4",
224 const.AIRCRAFT_B762 : "762",
225 const.AIRCRAFT_B763 : "763",
226 const.AIRCRAFT_CRJ2 : "CR2",
227 const.AIRCRAFT_F70 : "F70",
228 const.AIRCRAFT_DC3 : "LI2",
229 const.AIRCRAFT_T134 : "TU3",
230 const.AIRCRAFT_T154 : "TU5",
231 const.AIRCRAFT_YK40 : "YK4",
232 const.AIRCRAFT_B462 : "146" }
233
234 # FIXME: copied from web.BookedFlight
235 @staticmethod
236 def _decodeAircraftType(typeCode):
237 """Decode the aircraft type from the given typeCode."""
238 if typeCode in BookedFlight.TYPECODE2TYPE:
239 return BookedFlight.TYPECODE2TYPE[typeCode]
240 else:
241 raise Exception("Invalid aircraft type code: '" + typeCode + "'")
242
243 @staticmethod
244 def _decodeStatus(status):
245 """Decode the status from the status string."""
246 if status=="booked":
247 return BookedFlight.STATUS_BOOKED
248 elif status=="reported":
249 return BookedFlight.STATUS_REPORTED
250 elif status=="accepted":
251 return BookedFlight.STATUS_ACCEPTED
252 elif status=="rejected":
253 return BookedFlight.STATUS_REJECTED
254 else:
255 raise Exception("Invalid flight status code: '" + status + "'")
256
257 # FIXME: copied from web.BookedFlight
258 @staticmethod
259 def getDateTime(date, time):
260 """Get a datetime object from the given textual date and time."""
261 return datetime.datetime.strptime(date + " " + time,
262 "%Y-%m-%d %H:%M:%S")
263
264 # FIXME: copied from web.BookedFlight
265 STATUS_BOOKED = 1
266
267 # FIXME: copied from web.BookedFlight
268 STATUS_REPORTED = 2
269
270 # FIXME: copied from web.BookedFlight
271 STATUS_ACCEPTED = 3
272
273 # FIXME: copied from web.BookedFlight
274 STATUS_REJECTED = 4
275
276 # The instructions for the construction
277 _instructions = {
278 "numPassengers" : int,
279 "numCrew" : int,
280 "bagWeight" : int,
281 "cargoWeight" : int,
282 "mailWeight" : int,
283 "aircraftType" : lambda value: BookedFlight._decodeAircraftType(value),
284 "status" : lambda value: BookedFlight._decodeStatus(value)
285 }
286
287 def __init__(self, value):
288 """Construct the booked flight object from the given RPC result
289 value."""
290 self.status = BookedFlight.STATUS_BOOKED
291 super(BookedFlight, self).__init__(value, BookedFlight._instructions)
292 self.departureTime = \
293 BookedFlight.getDateTime(self.date, self.departureTime)
294 self.arrivalTime = \
295 BookedFlight.getDateTime(self.date, self.arrivalTime)
296 if self.arrivalTime<self.departureTime:
297 self.arrivalTime += datetime.timedelta(days = 1)
298
299 def writeIntoFile(self, f):
300 """Write the flight into a file."""
301 print("callsign=%s" % (self.callsign,), file=f)
302 date = self.departureTime.date()
303 print("date=%04d-%02d-%0d" % (date.year, date.month, date.day), file=f)
304 print("dep_airport=%s" % (self.departureICAO,), file=f)
305 print("dest_airport=%s" % (self.arrivalICAO,), file=f)
306 print("planecode=%s" % \
307 (BookedFlight.TYPE2TYPECODE[self.aircraftType],), file=f)
308 print("planetype=%s" % (self.aircraftTypeName,), file=f)
309 print("tail_nr=%s" % (self.tailNumber,), file=f)
310 print("passenger=%d" % (self.numPassengers,), file=f)
311 print("crew=%d" % (self.numCrew,), file=f)
312 print("bag=%d" % (self.bagWeight,), file=f)
313 print("cargo=%d" % (self.cargoWeight,), file=f)
314 print("mail=%d" % (self.mailWeight,), file=f)
315 print("flight_route=%s" % (self.route,), file=f)
316 departureTime = self.departureTime
317 print("departure_time=%02d\\:%02d\\:%02d" % \
318 (departureTime.hour, departureTime.minute, departureTime.second), file=f)
319 arrivalTime = self.arrivalTime
320 print("arrival_time=%02d\\:%02d\\:%02d" % \
321 (arrivalTime.hour, arrivalTime.minute, arrivalTime.second), file=f)
322 print("foglalas_id=%s" % ("0" if self.id is None else self.id,), file=f)
323
324#---------------------------------------------------------------------------------------
325
326class AcceptedFlight(RPCObject):
327 """A flight that has been already accepted."""
328 # The instructions for the construction
329 @staticmethod
330 def parseTimestamp(s):
331 """Parse the given RPC timestamp."""
332 dt = datetime.datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
333 return calendar.timegm(dt.utctimetuple())
334
335 _instructions = {
336 "bookedFlight" : lambda value: BookedFlight(value),
337 "numPassengers" : int,
338 "fuelUsed" : int,
339 "rating" : lambda value: float(value) if value else 0.0
340 }
341
342 def __init__(self, value):
343 """Construct the booked flight object from the given RPC result
344 value."""
345 super(AcceptedFlight, self).__init__(value, AcceptedFlight._instructions)
346 self.flightTimeStart = \
347 AcceptedFlight.parseTimestamp(self.flightDate + " " +
348 self.flightTimeStart)
349 self.flightTimeEnd = \
350 AcceptedFlight.parseTimestamp(self.flightDate + " " +
351 self.flightTimeEnd)
352 if self.flightTimeEnd<self.flightTimeStart:
353 self.flightTimeEnd += 24*60*60
354
355#---------------------------------------------------------------------------------------
356
357class Plane(rpccommon.Plane, RPCObject):
358 """An airplane in the fleet."""
359 _instructions = {
360 "status" : lambda value: rpccommon.Plane.str2status(value),
361 "gateNumber" : lambda value: value if value else None,
362 "typeCode": lambda value: BookedFlight._decodeAircraftType(value)
363 }
364
365 def __init__(self, value):
366 """Construct the plane."""
367 RPCObject.__init__(self, value, instructions = Plane._instructions)
368 self.aircraftType = self.typeCode
369 del self.typeCode
370
371#---------------------------------------------------------------------------------------
372
373class Fleet(rpccommon.Fleet):
374 """The fleet."""
375 def __init__(self, value):
376 """Construct the fleet."""
377 super(Fleet, self).__init__()
378 for planeValue in value:
379 self._addPlane(Plane(planeValue))
380
381#---------------------------------------------------------------------------------------
382
383class Registration(object):
384 """Data for registration."""
385 def __init__(self, surName, firstName, nameOrder,
386 yearOfBirth, emailAddress, emailAddressPublic,
387 vatsimID, ivaoID, phoneNumber, nationality, password):
388 """Construct the registration data."""
389 self.surName = surName
390 self.firstName = firstName
391 self.nameOrder = nameOrder
392 self.yearOfBirth = yearOfBirth
393 self.emailAddress = emailAddress
394 self.emailAddressPublic = 1 if emailAddressPublic is True else \
395 0 if emailAddressPublic is False else emailAddressPublic
396 self.vatsimID = "" if vatsimID is None else vatsimID
397 self.ivaoID = "" if ivaoID is None else ivaoID
398 self.phoneNumber = phoneNumber
399 self.nationality = nationality
400 self.password = password
401
402#---------------------------------------------------------------------------------------
403
404class RPCException(Exception):
405 """An exception thrown by RPC operations."""
406 def __init__(self, result, message = None):
407 """Construct the exception."""
408 self._result = result
409 if message is None:
410 message = "RPC call failed with result code: %d" % (result,)
411 super(RPCException, self).__init__(message)
412
413 @property
414 def result(self):
415 """Get the result code."""
416 return self._result
417
418#---------------------------------------------------------------------------------------
419
420class Client(object):
421 """The RPC client interface."""
422 # The client protocol version
423 VERSION = 2
424
425 # Result code: OK
426 RESULT_OK = 0
427
428 # Result code: the login has failed
429 RESULT_LOGIN_FAILED = 1
430
431 # Result code: the given session ID is unknown (it might have expired).
432 RESULT_SESSION_INVALID = 2
433
434 # Result code: some database error
435 RESULT_DATABASE_ERROR = 3
436
437 # Result code: invalid data
438 RESULT_INVALID_DATA = 4
439
440 # Result code: the flight does not exist
441 RESULT_FLIGHT_NOT_EXISTS = 101
442
443 # Result code: the flight has already been reported.
444 RESULT_FLIGHT_ALREADY_REPORTED = 102
445
446 # Result code: a user with the given e-mail address already exists
447 RESULT_EMAIL_ALREADY_REGISTERED = 103
448
449 def __init__(self, getCredentialsFn):
450 """Construct the client."""
451 self._getCredentialsFn = getCredentialsFn
452
453 self._server = jsonrpclib.Server(MAVA_BASE_URL + "/jsonrpc.php")
454
455 self._userName = None
456 self._passwordHash = None
457 self._sessionID = None
458 self._loginCount = 0
459
460 @property
461 def valid(self):
462 """Determine if the client is valid, i.e. there is a session ID
463 stored."""
464 return self._sessionID is not None
465
466 def setCredentials(self, userName, password):
467 """Set the credentials for future logins."""
468
469 self._userName = userName
470
471 md5 = hashlib.md5()
472 md5.update(password.encode("utf-8"))
473 self._passwordHash = md5.hexdigest()
474
475 self._sessionID = None
476
477 def register(self, registrationData):
478 """Register with the given data.
479
480 Returns a tuple of:
481 - the error code,
482 - the PID if there is no error."""
483 reply = Reply(self._server.register(registrationData))
484
485 return (reply.result,
486 reply.value["pid"] if reply.result==Client.RESULT_OK else None)
487
488 def login(self):
489 """Login using the given previously set credentials.
490
491 The session ID is stored in the object and used for later calls.
492
493 Returns the name of the pilot on success, or None on error."""
494 self._sessionID = None
495
496 reply = Reply(self._server.login(self._userName, self._passwordHash,
497 Client.VERSION))
498 if reply.result == Client.RESULT_OK:
499 self._loginCount += 1
500 self._sessionID = reply.value["sessionID"]
501
502 types = [BookedFlight.TYPECODE2TYPE[typeCode]
503 for typeCode in reply.value["typeCodes"]]
504
505 return (reply.value["name"], reply.value["rank"], types)
506 else:
507 return None
508
509 def getFlights(self):
510 """Get the flights available for performing."""
511 bookedFlights = []
512 reportedFlights = []
513 rejectedFlights = []
514
515 value = self._performCall(lambda sessionID:
516 self._server.getFlights(sessionID))
517 for flightData in value:
518 flight = BookedFlight(flightData)
519 if flight.status == BookedFlight.STATUS_BOOKED:
520 bookedFlights.append(flight)
521 elif flight.status == BookedFlight.STATUS_REPORTED:
522 reportedFlights.append(flight)
523 elif flight.status == BookedFlight.STATUS_REJECTED:
524 rejectedFlights.append(flight)
525
526 for flights in [bookedFlights, reportedFlights, rejectedFlights]:
527 flights.sort(key = lambda flight: flight.departureTime)
528
529 return (bookedFlights, reportedFlights, rejectedFlights)
530
531 def getAcceptedFlights(self):
532 """Get the flights that are already accepted."""
533 value = self._performCall(lambda sessionID:
534 self._server.getAcceptedFlights(sessionID))
535 flights = []
536 for flight in value:
537 flights.append(AcceptedFlight(flight))
538 return flights
539
540 def getEntryExamStatus(self):
541 """Get the status of the exams needed for joining MAVA."""
542 value = self._performCall(lambda sessionID:
543 self._server.getEntryExamStatus(sessionID))
544 return (value["entryExamPassed"], value["entryExamLink"],
545 value["checkFlightStatus"], value["madeFO"])
546
547 def getFleet(self):
548 """Query and return the fleet."""
549 value = self._performCall(lambda sessionID:
550 self._server.getFleet(sessionID))
551
552 return Fleet(value)
553
554 def updatePlane(self, tailNumber, status, gateNumber):
555 """Update the state and position of the plane with the given tail
556 number."""
557 status = rpccommon.Plane.status2str(status)
558 self._performCall(lambda sessionID:
559 self._server.updatePlane(sessionID, tailNumber,
560 status, gateNumber))
561
562 def addPIREP(self, flightID, pirep, update = False):
563 """Add the PIREP for the given flight."""
564 (result, _value) = \
565 self._performCall(lambda sessionID:
566 self._server.addPIREP(sessionID, flightID, pirep,
567 update),
568 acceptResults = [Client.RESULT_FLIGHT_ALREADY_REPORTED,
569 Client.RESULT_FLIGHT_NOT_EXISTS])
570 return result
571
572 def updateOnlineACARS(self, acars):
573 """Update the online ACARS from the given data."""
574 self._performCall(lambda sessionID:
575 self._server.updateOnlineACARS(sessionID, acars))
576
577 def setCheckFlightPassed(self, type):
578 """Mark the check flight of the user passed with the given type."""
579 self._performCall(lambda sessionID:
580 self._server.setCheckFlightPassed(sessionID, type))
581
582 def getPIREP(self, flightID):
583 """Get the PIREP data for the flight with the given ID."""
584 value = self._performCall(lambda sessionID:
585 self._server.getPIREP(sessionID, flightID))
586 return value
587
588 def reflyFlights(self, flightIDs):
589 """Mark the flights with the given IDs for reflying."""
590 self._performCall(lambda sessionID:
591 self._server.reflyFlights(sessionID, flightIDs))
592
593 def deleteFlights(self, flightIDs):
594 """Delete the flights with the given IDs."""
595 self._performCall(lambda sessionID:
596 self._server.deleteFlights(sessionID, flightIDs))
597
598 def getTimetable(self, date, types = None):
599 """Get the time table for the given date restricted to the given list
600 of type codes, if any."""
601 typeCodes = None if types is None else \
602 [BookedFlight.TYPE2TYPECODE[type] for type in types]
603
604 values = self._performCall(lambda sessionID:
605 self._server.getTimetable(sessionID,
606 date.strftime("%Y-%m-%d"),
607 date.weekday()+1,
608 typeCodes))
609 return ScheduledFlightPair.scheduledFlights2Pairs([ScheduledFlight(value)
610 for value in values],
611 date)
612
613 def bookFlights(self, flightIDs, date, tailNumber):
614 """Book the flights with the given IDs on the given date to be flown
615 with the plane of the given tail number."""
616 values = self._performCall(lambda sessionID:
617 self._server.bookFlights(sessionID,
618 flightIDs,
619 date.strftime("%Y-%m-%d"),
620 tailNumber))
621 return [BookedFlight(value) for value in values]
622
623 def _performCall(self, callFn, acceptResults = []):
624 """Perform a call using the given call function.
625
626 acceptResults should be a list of result codes that should be accepted
627 besides RESULT_OK. If this list is not empty, the returned value is a
628 tuple of the result code and the corresponding value. Otherwise only
629 RESULT_OK is accepted, and the value is returned.
630
631 All other error codes are converted to exceptions."""
632 numAttempts = 0
633 while True:
634 reply = Reply(callFn(self._ensureSession()))
635 numAttempts += 1
636 result = reply.result
637 if result==Client.RESULT_SESSION_INVALID:
638 self._sessionID = None
639 if numAttempts==3:
640 raise RPCException(result)
641 elif result!=Client.RESULT_OK and result not in acceptResults:
642 raise RPCException(result)
643 elif acceptResults:
644 return (result, reply.value)
645 else:
646 return reply.value
647
648 def _ensureSession(self):
649 """Ensure that there is a valid session ID."""
650 while self._sessionID is None:
651 if self._userName is not None and self._passwordHash is not None:
652 if not self.login():
653 self._userName = self._passwordHash = None
654
655 if self._userName is None or self._passwordHash is None:
656 (self._userName, password) = self._getCredentialsFn()
657 if self._userName is None:
658 raise RPCException(Client.RESULT_LOGIN_FAILED)
659
660 md5 = hashlib.md5()
661 md5.update(password)
662 self._passwordHash = md5.hexdigest()
663
664 return self._sessionID
665
666#---------------------------------------------------------------------------------------
Note: See TracBrowser for help on using the repository browser.