Package Documentation¶
The f1-2019-telemetry package provides support for interpreting telemetry information as sent out over the network by the F1 2019 game by CodeMasters. It also provides command line tools to record, playback, and monitor F1 2019 session data.
With each yearly release of the F1 series game, CodeMasters post a descripton of the corresponding telemetry packet format on their forum. For F1 2019, the packet format is described here:
A formatted version of this specification, with some small issues fixed, is included in the f1-2019-telemetry package and can be found here.
The f1-2019-telemetry package should work on Python 3.6 and above.
Installation¶
The f1-2019-telemetry package is hosted on PyPI. To install it in your Python 3 environment, type:
pip3 install f1-2019-telemetry
When this completes, you should be able to start your Python 3 interpreter and execute this:
import f1_2019_telemetry.packet
help(f1_2019_telemetry.packet)
Apart from the f1_2019_telemetry package (and its main module f1_2019_telemetry.packet), the pip3 install
command will also install some command-line utilities that can be used to record, playback, and monitor F1 2019 telemetry data.
Refer to the Command Line Tools section for more information.
Usage¶
If you want to write your own Python script to process F1 2019 telemetry data, you will need to set up the reception of UDP packets yourself. After that, use the function unpack_udp_packet() to unpack the binary packet to an appropriate object with all the data fields present.
A minimalistic example is as follows:
1 2 3 4 5 6 7 8 9 10 11 12 | import socket
from f1_2019_telemetry.packets import unpack_udp_packet
udp_socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM)
udp_socket.bind(('', 20777))
while True:
udp_packet = udp_socket.recv(2048)
packet = unpack_udp_packet(udp_packet)
print("Received:", packet)
print()
|
This example opens a UDP socket on port 20777, which is the default port that the F1 2019 game uses to send packages; it then waits for packages and, upon reception, prints their full contents.
To generate some data, start your F1 2019 game, and go to the Telemetry Settings (these can be found under Game Options / Settings).
Make sure that the UDP Telemetry setting is set to On.
The UDP Broadcast setting should be either set to On, or it should be set to Off, and then the UDP IP Address setting should be set to the IP address of the computer on which you intend to run the Python script that will capture game session data. For example, if you want the Python script to run on the same computer that runs the game, and you don’t want to send out UDP packets to all devices in your home network, you can set the UDP Broadcast setting to Off and the UDP IP Address setting to 127.0.0.1.
The UDP Port setting can be keep its default value of 20777.
The UDP Send Rate setting can be set to 60, assuming you have a sufficiently powerful computer to run the game.
The UDP Format setting should be set to 2019.
Now, if you start a race session with the Python script given above running, you should see a continuous stream of game data being printed to your command line terminal.
The example script given above is about as simple as it can be to capture game data. For more elaborate examples, check the source code of the provided f1_2019_telemetry.cli.monitor and f1_2019_telemetry.cli.recorder scripts. Note that those examples are considerably more complicated because they use multi-threading.
Command Line Tools¶
The f1-2019-telemetry package installs three command-line tools that provide basic recording, playback, and session monitoring support. Below, we reproduce their command-line help for reference.
f1-2019-telemetry-recorder script¶
usage: f1-2019-telemetry-recorder [-h] [-p PORT] [-i INTERVAL]
Record F1 2019 telemetry data to SQLite3 files.
optional arguments:
-h, --help show this help message and exit
-p PORT, --port PORT UDP port to listen to (default: 20777)
-i INTERVAL, --interval INTERVAL interval for writing incoming data to SQLite3 file, in seconds (default: 1.0)
f1-2019-telemetry-player script¶
usage: f1-2019-telemetry-player [-h] [-r REALTIME_FACTOR] [-d DESTINATION] [-p PORT] filename
Replay an F1 2019 session as UDP packets.
positional arguments:
filename SQLite3 file to replay packets from
optional arguments:
-h, --help show this help message and exit
-r REALTIME_FACTOR, --rtf REALTIME_FACTOR playback real-time factor (higher is faster, default=1.0)
-d DESTINATION, --destination DESTINATION destination UDP address; omit to use broadcast (default)
-p PORT, --port PORT destination UDP port (default: 20777)
f1-2019-telemetry-monitor script¶
usage: f1-2019-telemetry-monitor [-h] [-p PORT]
Monitor UDP port for incoming F1 2019 telemetry data and print information.
optional arguments:
-h, --help show this help message and exit
-p PORT, --port PORT UDP port to listen to (default: 20777)
Package Source Code¶
The source code of all modules in the package is pretty well documented and easy to follow. We reproduce it here for reference.
Module: f1_2019_telemetry.packets¶
Module f1_2019_telemetry.packets is the main module of the package. It implements ctypes struct types for all kinds of packets, and it implements the unpack_udp_packet() function that take the contents of a raw UDP packet and interprets is as the appropriate telemetry packet, if possible.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 | """F1 2019 UDP Telemetry support package
This package is based on the CodeMasters Forum post documenting the F1 2019 packet format:
https://forums.codemasters.com/topic/38920-f1-2019-udp-specification/
Compared to the definitions given there, the Python version has the following changes:
(1) In the 'PacketMotionData' structure, the comments for the three m_angularAcceleration{X,Y,Z} fields erroneously
refer to 'velocity' rather than 'acceleration'. This was corrected.
(2) In the 'CarSetupData' structure, the comment of the m_rearAntiRollBar refer to rear instead of front. This was corrected.
(3) In the Driver IDs table, driver 34 has name "Wilheim Kaufmann".
This is a typo; whenever this driver is encountered in the game, his name is given as "Wilhelm Kaufmann".
"""
import ctypes
import enum
#########################################################
# #
# __________ PackedLittleEndianStructure __________ #
# #
#########################################################
class PackedLittleEndianStructure(ctypes.LittleEndianStructure):
"""The standard ctypes LittleEndianStructure, but tightly packed (no field padding), and with a proper repr() function.
This is the base type for all structures in the telemetry data.
"""
_pack_ = 1
def __repr__(self):
fstr_list = []
for (fname, ftype) in self._fields_:
value = getattr(self, fname)
if isinstance(value, (PackedLittleEndianStructure, int, float, bytes)):
vstr = repr(value)
elif isinstance(value, ctypes.Array):
vstr = "[{}]".format(", ".join(repr(e) for e in value))
else:
raise RuntimeError("Bad value {!r} of type {!r}".format(value, type(value)))
fstr = "{}={}".format(fname, vstr)
fstr_list.append(fstr)
return "{}({})".format(self.__class__.__name__, ", ".join(fstr_list))
###########################################
# #
# __________ Packet Header __________ #
# #
###########################################
class PacketHeader(PackedLittleEndianStructure):
"""The header for each of the UDP telemetry packets."""
_fields_ = [
('packetFormat' , ctypes.c_uint16), # 2019
('gameMajorVersion' , ctypes.c_uint8 ), # Game major version - "X.00"
('gameMinorVersion' , ctypes.c_uint8 ), # Game minor version - "1.XX"
('packetVersion' , ctypes.c_uint8 ), # Version of this packet type, all start from 1
('packetId' , ctypes.c_uint8 ), # Identifier for the packet type, see below
('sessionUID' , ctypes.c_uint64), # Unique identifier for the session
('sessionTime' , ctypes.c_float ), # Session timestamp
('frameIdentifier' , ctypes.c_uint32), # Identifier for the frame the data was retrieved on
('playerCarIndex' , ctypes.c_uint8 ) # Index of player's car in the array
]
@enum.unique
class PacketID(enum.IntEnum):
"""Value as specified in the PacketHeader.packetId header field, used to distinguish packet types."""
MOTION = 0
SESSION = 1
LAP_DATA = 2
EVENT = 3
PARTICIPANTS = 4 # 0.2 Hz (once every five seconds)
CAR_SETUPS = 5
CAR_TELEMETRY = 6
CAR_STATUS = 7
PacketID.short_description = {
PacketID.MOTION : 'Motion',
PacketID.SESSION : 'Session',
PacketID.LAP_DATA : 'Lap Data',
PacketID.EVENT : 'Event',
PacketID.PARTICIPANTS : 'Participants',
PacketID.CAR_SETUPS : 'Car Setups',
PacketID.CAR_TELEMETRY : 'Car Telemetry',
PacketID.CAR_STATUS : 'Car Status'
}
PacketID.long_description = {
PacketID.MOTION : 'Contains all motion data for player\'s car – only sent while player is in control',
PacketID.SESSION : 'Data about the session – track, time left',
PacketID.LAP_DATA : 'Data about all the lap times of cars in the session',
PacketID.EVENT : 'Various notable events that happen during a session',
PacketID.PARTICIPANTS : 'List of participants in the session, mostly relevant for multiplayer',
PacketID.CAR_SETUPS : 'Packet detailing car setups for cars in the race',
PacketID.CAR_TELEMETRY : 'Telemetry data for all cars',
PacketID.CAR_STATUS : 'Status data for all cars such as damage'
}
#########################################################
# #
# __________ Packet ID 0 : MOTION PACKET __________ #
# #
#########################################################
class CarMotionData_V1(PackedLittleEndianStructure):
"""This type is used for the 20-element 'carMotionData' array of the PacketMotionData_V1 type, defined below."""
_fields_ = [
('worldPositionX' , ctypes.c_float), # World space X position
('worldPositionY' , ctypes.c_float), # World space Y position
('worldPositionZ' , ctypes.c_float), # World space Z position
('worldVelocityX' , ctypes.c_float), # Velocity in world space X
('worldVelocityY' , ctypes.c_float), # Velocity in world space Y
('worldVelocityZ' , ctypes.c_float), # Velocity in world space Z
('worldForwardDirX' , ctypes.c_int16), # World space forward X direction (normalised)
('worldForwardDirY' , ctypes.c_int16), # World space forward Y direction (normalised)
('worldForwardDirZ' , ctypes.c_int16), # World space forward Z direction (normalised)
('worldRightDirX' , ctypes.c_int16), # World space right X direction (normalised)
('worldRightDirY' , ctypes.c_int16), # World space right Y direction (normalised)
('worldRightDirZ' , ctypes.c_int16), # World space right Z direction (normalised)
('gForceLateral' , ctypes.c_float), # Lateral G-Force component
('gForceLongitudinal' , ctypes.c_float), # Longitudinal G-Force component
('gForceVertical' , ctypes.c_float), # Vertical G-Force component
('yaw' , ctypes.c_float), # Yaw angle in radians
('pitch' , ctypes.c_float), # Pitch angle in radians
('roll' , ctypes.c_float) # Roll angle in radians
]
class PacketMotionData_V1(PackedLittleEndianStructure):
"""The motion packet gives physics data for all the cars being driven.
There is additional data for the car being driven with the goal of being able to drive a motion platform setup.
N.B. For the normalised vectors below, to convert to float values divide by 32767.0f – 16-bit signed values are
used to pack the data and on the assumption that direction values are always between -1.0f and 1.0f.
Frequency: Rate as specified in menus
Size: 1343 bytes
Version: 1
"""
_fields_ = [
('header' , PacketHeader ), # Header
('carMotionData' , CarMotionData_V1 * 20), # Data for all cars on track
# Extra player car ONLY data
('suspensionPosition' , ctypes.c_float * 4 ), # Note: All wheel arrays have the following order:
('suspensionVelocity' , ctypes.c_float * 4 ), # RL, RR, FL, FR
('suspensionAcceleration' , ctypes.c_float * 4 ), # RL, RR, FL, FR
('wheelSpeed' , ctypes.c_float * 4 ), # Speed of each wheel
('wheelSlip' , ctypes.c_float * 4 ), # Slip ratio for each wheel
('localVelocityX' , ctypes.c_float ), # Velocity in local space
('localVelocityY' , ctypes.c_float ), # Velocity in local space
('localVelocityZ' , ctypes.c_float ), # Velocity in local space
('angularVelocityX' , ctypes.c_float ), # Angular velocity x-component
('angularVelocityY' , ctypes.c_float ), # Angular velocity y-component
('angularVelocityZ' , ctypes.c_float ), # Angular velocity z-component
('angularAccelerationX' , ctypes.c_float ), # Angular acceleration x-component
('angularAccelerationY' , ctypes.c_float ), # Angular acceleration y-component
('angularAccelerationZ' , ctypes.c_float ), # Angular acceleration z-component
('frontWheelsAngle' , ctypes.c_float ) # Current front wheels angle in radians
]
##########################################################
# #
# __________ Packet ID 1 : SESSION PACKET __________ #
# #
##########################################################
class MarshalZone_V1(PackedLittleEndianStructure):
"""This type is used for the 21-element 'marshalZones' array of the PacketSessionData_V1 type, defined below."""
_fields_ = [
('zoneStart' , ctypes.c_float), # Fraction (0..1) of way through the lap the marshal zone starts
('zoneFlag' , ctypes.c_int8 ) # -1 = invalid/unknown, 0 = none, 1 = green, 2 = blue, 3 = yellow, 4 = red
]
class PacketSessionData_V1(PackedLittleEndianStructure):
"""The session packet includes details about the current session in progress.
Frequency: 2 per second
Size: 149 bytes
Version: 1
"""
_fields_ = [
('header' , PacketHeader ), # Header
('weather' , ctypes.c_uint8 ), # Weather - 0 = clear, 1 = light cloud, 2 = overcast
# 3 = light rain, 4 = heavy rain, 5 = storm
('trackTemperature' , ctypes.c_int8 ), # Track temp. in degrees celsius
('airTemperature' , ctypes.c_int8 ), # Air temp. in degrees celsius
('totalLaps' , ctypes.c_uint8 ), # Total number of laps in this race
('trackLength' , ctypes.c_uint16 ), # Track length in metres
('sessionType' , ctypes.c_uint8 ), # 0 = unknown, 1 = P1, 2 = P2, 3 = P3, 4 = Short P
# 5 = Q1, 6 = Q2, 7 = Q3, 8 = Short Q, 9 = OSQ
# 10 = R, 11 = R2, 12 = Time Trial
('trackId' , ctypes.c_int8 ), # -1 for unknown, 0-21 for tracks, see appendix
('m_formula' , ctypes.c_uint8 ), # Formula, 0 = F1 Modern, 1 = F1 Classic, 2 = F2,
# 3 = F1 Generic
('sessionTimeLeft' , ctypes.c_uint16 ), # Time left in session in seconds
('sessionDuration' , ctypes.c_uint16 ), # Session duration in seconds
('pitSpeedLimit' , ctypes.c_uint8 ), # Pit speed limit in kilometres per hour
('gamePaused' , ctypes.c_uint8 ), # Whether the game is paused
('isSpectating' , ctypes.c_uint8 ), # Whether the player is spectating
('spectatorCarIndex' , ctypes.c_uint8 ), # Index of the car being spectated
('sliProNativeSupport' , ctypes.c_uint8 ), # SLI Pro support, 0 = inactive, 1 = active
('numMarshalZones' , ctypes.c_uint8 ), # Number of marshal zones to follow
('marshalZones' , MarshalZone_V1 * 21), # List of marshal zones – max 21
('safetyCarStatus' , ctypes.c_uint8 ), # 0 = no safety car, 1 = full safety car
# 2 = virtual safety car
('networkGame' , ctypes.c_uint8 ) # 0 = offline, 1 = online
]
###########################################################
# #
# __________ Packet ID 2 : LAP DATA PACKET __________ #
# #
###########################################################
class LapData_V1(PackedLittleEndianStructure):
"""This type is used for the 20-element 'lapData' array of the PacketLapData_V1 type, defined below."""
_fields_ = [
('lastLapTime' , ctypes.c_float), # Last lap time in seconds
('currentLapTime' , ctypes.c_float), # Current time around the lap in seconds
('bestLapTime' , ctypes.c_float), # Best lap time of the session in seconds
('sector1Time' , ctypes.c_float), # Sector 1 time in seconds
('sector2Time' , ctypes.c_float), # Sector 2 time in seconds
('lapDistance' , ctypes.c_float), # Distance vehicle is around current lap in metres – could
# be negative if line hasn’t been crossed yet
('totalDistance' , ctypes.c_float), # Total distance travelled in session in metres – could
# be negative if line hasn’t been crossed yet
('safetyCarDelta' , ctypes.c_float), # Delta in seconds for safety car
('carPosition' , ctypes.c_uint8), # Car race position
('currentLapNum' , ctypes.c_uint8), # Current lap number
('pitStatus' , ctypes.c_uint8), # 0 = none, 1 = pitting, 2 = in pit area
('sector' , ctypes.c_uint8), # 0 = sector1, 1 = sector2, 2 = sector3
('currentLapInvalid' , ctypes.c_uint8), # Current lap invalid - 0 = valid, 1 = invalid
('penalties' , ctypes.c_uint8), # Accumulated time penalties in seconds to be added
('gridPosition' , ctypes.c_uint8), # Grid position the vehicle started the race in
('driverStatus' , ctypes.c_uint8), # Status of driver - 0 = in garage, 1 = flying lap
# 2 = in lap, 3 = out lap, 4 = on track
('resultStatus' , ctypes.c_uint8) # Result status - 0 = invalid, 1 = inactive, 2 = active
# 3 = finished, 4 = disqualified, 5 = not classified
# 6 = retired
]
class PacketLapData_V1(PackedLittleEndianStructure):
"""The lap data packet gives details of all the cars in the session.
Frequency: Rate as specified in menus
Size: 843 bytes
Version: 1
"""
_fields_ = [
('header' , PacketHeader ), # Header
('lapData' , LapData_V1 * 20) # Lap data for all cars on track
]
########################################################
# #
# __________ Packet ID 3 : EVENT PACKET __________ #
# #
########################################################
class PacketEventData_V1(PackedLittleEndianStructure):
"""This packet gives details of events that happen during the course of a session.
Frequency: When the event occurs
Size: 32 bytes
Version: 1
"""
_fields_ = [
('header' , PacketHeader ), # Header
('eventStringCode' , ctypes.c_char * 4), # Event string code, see below
# Event details - should be interpreted differently for each type
('vehicleIdx' , ctypes.c_uint8 ), # Vehicle index of car (valid for events: FTLP, RTMT, TMPT, RCWN)
('lapTime' , ctypes.c_float ) # Lap time is in seconds (valid for events: FTLP)
]
@enum.unique
class EventStringCode(enum.Enum):
"""Value as specified in the PacketEventData_V1.eventStringCode header field, used to distinguish packet types."""
SSTA = b'SSTA'
SEND = b'SEND'
FTLP = b'FTLP'
RTMT = b'RTMT'
DRSE = b'DRSE'
DRSD = b'DRSD'
TMPT = b'TMPT'
CHQF = b'CHQF'
RCWN = b'RCWN'
EventStringCode.short_description = {
EventStringCode.SSTA : 'Session Started',
EventStringCode.SEND : 'Session Ended',
EventStringCode.FTLP : 'Fastest Lap',
EventStringCode.RTMT : 'Retirement',
EventStringCode.DRSE : 'DRS enabled',
EventStringCode.DRSD : 'DRS disabled',
EventStringCode.TMPT : 'Team mate in pits',
EventStringCode.CHQF : 'Chequered flag',
EventStringCode.RCWN : 'Race Winner'
}
EventStringCode.long_description = {
EventStringCode.SSTA : 'Sent when the session starts',
EventStringCode.SEND : 'Sent when the session ends',
EventStringCode.FTLP : 'When a driver achieves the fastest lap',
EventStringCode.RTMT : 'When a driver retires',
EventStringCode.DRSE : 'Race control have enabled DRS',
EventStringCode.DRSD : 'Race control have disabled DRS',
EventStringCode.TMPT : 'Your team mate has entered the pits',
EventStringCode.CHQF : 'The chequered flag has been waved',
EventStringCode.RCWN : 'The race winner is announced'
}
###############################################################
# #
# __________ Packet ID 4 : PARTICIPANTS PACKET __________ #
# #
###############################################################
class ParticipantData_V1(PackedLittleEndianStructure):
"""This type is used for the 20-element 'participants' array of the PacketParticipantsData_V1 type, defined below."""
_fields_ = [
('aiControlled' , ctypes.c_uint8 ), # Whether the vehicle is AI (1) or Human (0) controlled
('driverId' , ctypes.c_uint8 ), # Driver id - see appendix
('teamId' , ctypes.c_uint8 ), # Team id - see appendix
('raceNumber' , ctypes.c_uint8 ), # Race number of the car
('nationality' , ctypes.c_uint8 ), # Nationality of the driver
('name' , ctypes.c_char * 48), # Name of participant in UTF-8 format – null terminated
# Will be truncated with … (U+2026) if too long
('yourTelemetry', ctypes.c_uint8 ) # The player's UDP setting, 0 = restricted, 1 = public
]
class PacketParticipantsData_V1(PackedLittleEndianStructure):
"""This is a list of participants in the race.
If the vehicle is controlled by AI, then the name will be the driver name.
If this is a multiplayer game, the names will be the Steam Id on PC, or the LAN name if appropriate.
On Xbox One, the names will always be the driver name, on PS4 the name will be the LAN name if playing a LAN game,
otherwise it will be the driver name.
Frequency: Every 5 seconds
Size: 1104 bytes
Version: 1
"""
_fields_ = [
('header' , PacketHeader ), # Header
('numActiveCars' , ctypes.c_uint8 ), # Number of active cars in the data – should match number of
# cars on HUD
('participants' , ParticipantData_V1 * 20)
]
#############################################################
# #
# __________ Packet ID 5 : CAR SETUPS PACKET __________ #
# #
#############################################################
class CarSetupData_V1(PackedLittleEndianStructure):
"""This type is used for the 20-element 'carSetups' array of the PacketCarSetupData_V1 type, defined below."""
_fields_ = [
('frontWing' , ctypes.c_uint8), # Front wing aero
('rearWing' , ctypes.c_uint8), # Rear wing aero
('onThrottle' , ctypes.c_uint8), # Differential adjustment on throttle (percentage)
('offThrottle' , ctypes.c_uint8), # Differential adjustment off throttle (percentage)
('frontCamber' , ctypes.c_float), # Front camber angle (suspension geometry)
('rearCamber' , ctypes.c_float), # Rear camber angle (suspension geometry)
('frontToe' , ctypes.c_float), # Front toe angle (suspension geometry)
('rearToe' , ctypes.c_float), # Rear toe angle (suspension geometry)
('frontSuspension' , ctypes.c_uint8), # Front suspension
('rearSuspension' , ctypes.c_uint8), # Rear suspension
('frontAntiRollBar' , ctypes.c_uint8), # Front anti-roll bar
('rearAntiRollBar' , ctypes.c_uint8), # Rear anti-roll bar
('frontSuspensionHeight' , ctypes.c_uint8), # Front ride height
('rearSuspensionHeight' , ctypes.c_uint8), # Rear ride height
('brakePressure' , ctypes.c_uint8), # Brake pressure (percentage)
('brakeBias' , ctypes.c_uint8), # Brake bias (percentage)
('frontTyrePressure' , ctypes.c_float), # Front tyre pressure (PSI)
('rearTyrePressure' , ctypes.c_float), # Rear tyre pressure (PSI)
('ballast' , ctypes.c_uint8), # Ballast
('fuelLoad' , ctypes.c_float) # Fuel load
]
class PacketCarSetupData_V1(PackedLittleEndianStructure):
"""This packet details the car setups for each vehicle in the session.
Note that in multiplayer games, other player cars will appear as blank, you will only be able to see your car setup and AI cars.
Frequency: 2 per second
Size: 843 bytes
Version: 1
"""
_fields_ = [
('header' , PacketHeader ), # Header
('carSetups' , CarSetupData_V1 * 20)
]
################################################################
# #
# __________ Packet ID 6 : CAR TELEMETRY PACKET __________ #
# #
################################################################
class CarTelemetryData_V1(PackedLittleEndianStructure):
"""This type is used for the 20-element 'carTelemetryData' array of the PacketCarTelemetryData_V1 type, defined below."""
_fields_ = [
('speed' , ctypes.c_uint16 ), # Speed of car in kilometres per hour
('throttle' , ctypes.c_float ), # Amount of throttle applied (0.0 to 1.0)
('steer' , ctypes.c_float ), # Steering (-1.0 (full lock left) to 1.0 (full lock right))
('brake' , ctypes.c_float ), # Amount of brake applied (0 to 1.0)
('clutch' , ctypes.c_uint8 ), # Amount of clutch applied (0 to 100)
('gear' , ctypes.c_int8 ), # Gear selected (1-8, N=0, R=-1)
('engineRPM' , ctypes.c_uint16 ), # Engine RPM
('drs' , ctypes.c_uint8 ), # 0 = off, 1 = on
('revLightsPercent' , ctypes.c_uint8 ), # Rev lights indicator (percentage)
('brakesTemperature' , ctypes.c_uint16 * 4), # Brakes temperature (celsius)
('tyresSurfaceTemperature' , ctypes.c_uint16 * 4), # Tyres surface temperature (celsius)
('tyresInnerTemperature' , ctypes.c_uint16 * 4), # Tyres inner temperature (celsius)
('engineTemperature' , ctypes.c_uint16 ), # Engine temperature (celsius)
('tyresPressure' , ctypes.c_float * 4), # Tyres pressure (PSI)
('surfaceType' , ctypes.c_uint8 * 4) # Driving surface, see appendices
]
class PacketCarTelemetryData_V1(PackedLittleEndianStructure):
"""This packet details telemetry for all the cars in the race.
It details various values that would be recorded on the car such as speed, throttle application, DRS etc.
Frequency: Rate as specified in menus
Size: 1347 bytes
Version: 1
"""
_fields_ = [
('header' , PacketHeader ), # Header
('carTelemetryData' , CarTelemetryData_V1 * 20),
('buttonStatus' , ctypes.c_uint32 ) # Bit flags specifying which buttons are being
# pressed currently - see appendices
]
#############################################################
# #
# __________ Packet ID 7 : CAR STATUS PACKET __________ #
# #
#############################################################
class CarStatusData_V1(PackedLittleEndianStructure):
"""This type is used for the 20-element 'carStatusData' array of the PacketCarStatusData_V1 type, defined below.
There is some data in the Car Status packets that you may not want other players seeing if you are in a multiplayer game.
This is controlled by the "Your Telemetry" setting in the Telemetry options. The options are:
Restricted (Default) – other players viewing the UDP data will not see values for your car;
Public – all other players can see all the data for your car.
Note: You can always see the data for the car you are driving regardless of the setting.
The following data items are set to zero if the player driving the car in question has their "Your Telemetry" set to "Restricted":
fuelInTank
fuelCapacity
fuelMix
fuelRemainingLaps
frontBrakeBias
frontLeftWingDamage
frontRightWingDamage
rearWingDamage
engineDamage
gearBoxDamage
tyresWear (All four wheels)
tyresDamage (All four wheels)
ersDeployMode
ersStoreEnergy
ersDeployedThisLap
ersHarvestedThisLapMGUK
ersHarvestedThisLapMGUH
"""
_fields_ = [
('tractionControl' , ctypes.c_uint8 ), # 0 (off) - 2 (high)
('antiLockBrakes' , ctypes.c_uint8 ), # 0 (off) - 1 (on)
('fuelMix' , ctypes.c_uint8 ), # Fuel mix - 0 = lean, 1 = standard, 2 = rich, 3 = max
('frontBrakeBias' , ctypes.c_uint8 ), # Front brake bias (percentage)
('pitLimiterStatus' , ctypes.c_uint8 ), # Pit limiter status - 0 = off, 1 = on
('fuelInTank' , ctypes.c_float ), # Current fuel mass
('fuelCapacity' , ctypes.c_float ), # Fuel capacity
('fuelRemainingLaps' , ctypes.c_float ), # Fuel remaining in terms of laps (value on MFD)
('maxRPM' , ctypes.c_uint16 ), # Cars max RPM, point of rev limiter
('idleRPM' , ctypes.c_uint16 ), # Cars idle RPM
('maxGears' , ctypes.c_uint8 ), # Maximum number of gears
('drsAllowed' , ctypes.c_uint8 ), # 0 = not allowed, 1 = allowed, -1 = unknown
('tyresWear' , ctypes.c_uint8 * 4), # Tyre wear percentage
('actualTyreCompound' , ctypes.c_uint8 ), # F1 Modern - 16 = C5, 17 = C4, 18 = C3, 19 = C2, 20 = C1
# 7 = inter, 8 = wet
# F1 Classic - 9 = dry, 10 = wet
# F2 – 11 = super soft, 12 = soft, 13 = medium, 14 = hard
# 15 = wet
('tyreVisualCompound' , ctypes.c_uint8 ), # F1 visual (can be different from actual compound)
# 16 = soft, 17 = medium, 18 = hard, 7 = inter, 8 = wet
# F1 Classic – same as above
# F2 – same as above
('tyresDamage' , ctypes.c_uint8 * 4), # Tyre damage (percentage)
('frontLeftWingDamage' , ctypes.c_uint8 ), # Front left wing damage (percentage)
('frontRightWingDamage' , ctypes.c_uint8 ), # Front right wing damage (percentage)
('rearWingDamage' , ctypes.c_uint8 ), # Rear wing damage (percentage)
('engineDamage' , ctypes.c_uint8 ), # Engine damage (percentage)
('gearBoxDamage' , ctypes.c_uint8 ), # Gear box damage (percentage)
('vehicleFiaFlags' , ctypes.c_int8 ), # -1 = invalid/unknown, 0 = none, 1 = green
# 2 = blue, 3 = yellow, 4 = red
('ersStoreEnergy' , ctypes.c_float ), # ERS energy store in Joules
('ersDeployMode' , ctypes.c_uint8 ), # ERS deployment mode, 0 = none, 1 = low, 2 = medium
# 3 = high, 4 = overtake, 5 = hotlap
('ersHarvestedThisLapMGUK' , ctypes.c_float ), # ERS energy harvested this lap by MGU-K
('ersHarvestedThisLapMGUH' , ctypes.c_float ), # ERS energy harvested this lap by MGU-H
('ersDeployedThisLap' , ctypes.c_float ) # ERS energy deployed this lap
]
class PacketCarStatusData_V1(PackedLittleEndianStructure):
"""This packet details car statuses for all the cars in the race.
It includes values such as the damage readings on the car.
Frequency: Rate as specified in menus
Size: 1143 bytes
Version: 1
"""
_fields_ = [
('header' , PacketHeader ), # Header
('carStatusData' , CarStatusData_V1 * 20)
]
###################################################################
# #
# Appendices: various value enumerations used in the UDP output #
# #
###################################################################
TeamIDs = {
0 : 'Mercedes',
1 : 'Ferrari',
2 : 'Red Bull Racing',
3 : 'Williams',
4 : 'Racing Point',
5 : 'Renault',
6 : 'Toro Rosso',
7 : 'Haas',
8 : 'McLaren',
9 : 'Alfa Romeo',
10 : 'McLaren 1988',
11 : 'McLaren 1991',
12 : 'Williams 1992',
13 : 'Ferrari 1995',
14 : 'Williams 1996',
15 : 'McLaren 1998',
16 : 'Ferrari 2002',
17 : 'Ferrari 2004',
18 : 'Renault 2006',
19 : 'Ferrari 2007',
21 : 'Red Bull 2010',
22 : 'Ferrari 1976',
23 : 'ART Grand Prix',
24 : 'Campos Vexatec Racing',
25 : 'Carlin',
26 : 'Charouz Racing System',
27 : 'DAMS',
28 : 'Russian Time',
29 : 'MP Motorsport',
30 : 'Pertamina',
31 : 'McLaren 1990',
32 : 'Trident',
33 : 'BWT Arden',
34 : 'McLaren 1976',
35 : 'Lotus 1972',
36 : 'Ferrari 1979',
37 : 'McLaren 1982',
38 : 'Williams 2003',
39 : 'Brawn 2009',
40 : 'Lotus 1978',
63 : 'Ferrari 1990',
64 : 'McLaren 2010',
65 : 'Ferrari 2010'
}
DriverIDs = {
0 : 'Carlos Sainz',
1 : 'Daniil Kvyat',
2 : 'Daniel Ricciardo',
6 : 'Kimi Räikkönen',
7 : 'Lewis Hamilton',
9 : 'Max Verstappen',
10 : 'Nico Hulkenberg',
11 : 'Kevin Magnussen',
12 : 'Romain Grosjean',
13 : 'Sebastian Vettel',
14 : 'Sergio Perez',
15 : 'Valtteri Bottas',
19 : 'Lance Stroll',
20 : 'Arron Barnes',
21 : 'Martin Giles',
22 : 'Alex Murray',
23 : 'Lucas Roth',
24 : 'Igor Correia',
25 : 'Sophie Levasseur',
26 : 'Jonas Schiffer',
27 : 'Alain Forest',
28 : 'Jay Letourneau',
29 : 'Esto Saari',
30 : 'Yasar Atiyeh',
31 : 'Callisto Calabresi',
32 : 'Naota Izum',
33 : 'Howard Clarke',
34 : 'Wilhelm Kaufmann',
35 : 'Marie Laursen',
36 : 'Flavio Nieves',
37 : 'Peter Belousov',
38 : 'Klimek Michalski',
39 : 'Santiago Moreno',
40 : 'Benjamin Coppens',
41 : 'Noah Visser',
42 : 'Gert Waldmuller',
43 : 'Julian Quesada',
44 : 'Daniel Jones',
45 : 'Artem Markelov',
46 : 'Tadasuke Makino',
47 : 'Sean Gelael',
48 : 'Nyck De Vries',
49 : 'Jack Aitken',
50 : 'George Russell',
51 : 'Maximilian Günther',
52 : 'Nirei Fukuzumi',
53 : 'Luca Ghiotto',
54 : 'Lando Norris',
55 : 'Sérgio Sette Câmara',
56 : 'Louis Delétraz',
57 : 'Antonio Fuoco',
58 : 'Charles Leclerc',
59 : 'Pierre Gasly',
62 : 'Alexander Albon',
63 : 'Nicholas Latifi',
64 : 'Dorian Boccolacci',
65 : 'Niko Kari',
66 : 'Roberto Merhi',
67 : 'Arjun Maini',
68 : 'Alessio Lorandi',
69 : 'Ruben Meijer',
70 : 'Rashid Nair',
71 : 'Jack Tremblay',
74 : 'Antonio Giovinazzi',
75 : 'Robert Kubica'
}
TrackIDs = {
0 : 'Melbourne',
1 : 'Paul Ricard',
2 : 'Shanghai',
3 : 'Sakhir (Bahrain)',
4 : 'Catalunya',
5 : 'Monaco',
6 : 'Montreal',
7 : 'Silverstone',
8 : 'Hockenheim',
9 : 'Hungaroring',
10 : 'Spa',
11 : 'Monza',
12 : 'Singapore',
13 : 'Suzuka',
14 : 'Abu Dhabi',
15 : 'Texas',
16 : 'Brazil',
17 : 'Austria',
18 : 'Sochi',
19 : 'Mexico',
20 : 'Baku (Azerbaijan)',
21 : 'Sakhir Short',
22 : 'Silverstone Short',
23 : 'Texas Short',
24 : 'Suzuka Short'
}
NationalityIDs = {
1 : 'American',
2 : 'Argentinian',
3 : 'Australian',
4 : 'Austrian',
5 : 'Azerbaijani',
6 : 'Bahraini',
7 : 'Belgian',
8 : 'Bolivian',
9 : 'Brazilian',
10 : 'British',
11 : 'Bulgarian',
12 : 'Cameroonian',
13 : 'Canadian',
14 : 'Chilean',
15 : 'Chinese',
16 : 'Colombian',
17 : 'Costa Rican',
18 : 'Croatian',
19 : 'Cypriot',
20 : 'Czech',
21 : 'Danish',
22 : 'Dutch',
23 : 'Ecuadorian',
24 : 'English',
25 : 'Emirian',
26 : 'Estonian',
27 : 'Finnish',
28 : 'French',
29 : 'German',
30 : 'Ghanaian',
31 : 'Greek',
32 : 'Guatemalan',
33 : 'Honduran',
34 : 'Hong Konger',
35 : 'Hungarian',
36 : 'Icelander',
37 : 'Indian',
38 : 'Indonesian',
39 : 'Irish',
40 : 'Israeli',
41 : 'Italian',
42 : 'Jamaican',
43 : 'Japanese',
44 : 'Jordanian',
45 : 'Kuwaiti',
46 : 'Latvian',
47 : 'Lebanese',
48 : 'Lithuanian',
49 : 'Luxembourger',
50 : 'Malaysian',
51 : 'Maltese',
52 : 'Mexican',
53 : 'Monegasque',
54 : 'New Zealander',
55 : 'Nicaraguan',
56 : 'North Korean',
57 : 'Northern Irish',
58 : 'Norwegian',
59 : 'Omani',
60 : 'Pakistani',
61 : 'Panamanian',
62 : 'Paraguayan',
63 : 'Peruvian',
64 : 'Polish',
65 : 'Portuguese',
66 : 'Qatari',
67 : 'Romanian',
68 : 'Russian',
69 : 'Salvadoran',
70 : 'Saudi',
71 : 'Scottish',
72 : 'Serbian',
73 : 'Singaporean',
74 : 'Slovakian',
75 : 'Slovenian',
76 : 'South Korean',
77 : 'South African',
78 : 'Spanish',
79 : 'Swedish',
80 : 'Swiss',
81 : 'Thai',
82 : 'Turkish',
83 : 'Uruguayan',
84 : 'Ukrainian',
85 : 'Venezuelan',
86 : 'Welsh'
}
# These surface types are from physics data and show what type of contact each wheel is experiencing.
SurfaceTypes = {
0 : 'Tarmac',
1 : 'Rumble strip',
2 : 'Concrete',
3 : 'Rock',
4 : 'Gravel',
5 : 'Mud',
6 : 'Sand',
7 : 'Grass',
8 : 'Water',
9 : 'Cobblestone',
10 : 'Metal',
11 : 'Ridged'
}
@enum.unique
class ButtonFlag(enum.IntEnum):
"""Bit-mask values for the 'button' field in Car Telemetry Data packets."""
CROSS = 0x0001
TRIANGLE = 0x0002
CIRCLE = 0x0004
SQUARE = 0x0008
D_PAD_LEFT = 0x0010
D_PAD_RIGHT = 0x0020
D_PAD_UP = 0x0040
D_PAD_DOWN = 0x0080
OPTIONS = 0x0100
L1 = 0x0200
R1 = 0x0400
L2 = 0x0800
R2 = 0x1000
LEFT_STICK_CLICK = 0x2000
RIGHT_STICK_CLICK = 0x4000
ButtonFlag.description = {
ButtonFlag.CROSS : "Cross or A",
ButtonFlag.TRIANGLE : "Triangle or Y",
ButtonFlag.CIRCLE : "Circle or B",
ButtonFlag.SQUARE : "Square or X",
ButtonFlag.D_PAD_LEFT : "D-pad Left",
ButtonFlag.D_PAD_RIGHT : "D-pad Right",
ButtonFlag.D_PAD_UP : "D-pad Up",
ButtonFlag.D_PAD_DOWN : "D-pad Down",
ButtonFlag.OPTIONS : "Options or Menu",
ButtonFlag.L1 : "L1 or LB",
ButtonFlag.R1 : "R1 or RB",
ButtonFlag.L2 : "L2 or LT",
ButtonFlag.R2 : "R2 or RT",
ButtonFlag.LEFT_STICK_CLICK : "Left Stick Click",
ButtonFlag.RIGHT_STICK_CLICK : "Right Stick Click"
}
##################################
# #
# Decode UDP telemetry packets #
# #
##################################
# Map from (packetFormat, packetVersion, packetId) to a specific packet type.
HeaderFieldsToPacketType = {
(2019, 1, 0) : PacketMotionData_V1,
(2019, 1, 1) : PacketSessionData_V1,
(2019, 1, 2) : PacketLapData_V1,
(2019, 1, 3) : PacketEventData_V1,
(2019, 1, 4) : PacketParticipantsData_V1,
(2019, 1, 5) : PacketCarSetupData_V1,
(2019, 1, 6) : PacketCarTelemetryData_V1,
(2019, 1, 7) : PacketCarStatusData_V1
}
class UnpackError(Exception):
pass
def unpack_udp_packet(packet: bytes) -> PackedLittleEndianStructure:
"""Convert raw UDP packet to an appropriately-typed telemetry packet.
Args:
packet: the contents of the UDP packet to be unpacked.
Returns:
The decoded packet structure.
Raises:
UnpackError if a problem is detected.
"""
actual_packet_size = len(packet)
header_size = ctypes.sizeof(PacketHeader)
if actual_packet_size < header_size:
raise UnpackError("Bad telemetry packet: too short ({} bytes).".format(actual_packet_size))
header = PacketHeader.from_buffer_copy(packet)
key = (header.packetFormat, header.packetVersion, header.packetId)
if key not in HeaderFieldsToPacketType:
raise UnpackError("Bad telemetry packet: no match for key fields {!r}.".format(key))
packet_type = HeaderFieldsToPacketType[key]
expected_packet_size = ctypes.sizeof(packet_type)
if actual_packet_size != expected_packet_size:
raise UnpackError("Bad telemetry packet: bad size for {} packet; expected {} bytes but received {} bytes.".format(
packet_type.__name__, expected_packet_size, actual_packet_size))
return packet_type.from_buffer_copy(packet)
#########################################################################
# #
# Verify packet sizes if this module is executed rather than imported #
# #
#########################################################################
if __name__ == "__main__":
# Check all the packet sizes.
assert ctypes.sizeof(PacketMotionData_V1) == 1343
assert ctypes.sizeof(PacketSessionData_V1) == 149
assert ctypes.sizeof(PacketLapData_V1) == 843
assert ctypes.sizeof(PacketEventData_V1) == 32
assert ctypes.sizeof(PacketParticipantsData_V1) == 1104
assert ctypes.sizeof(PacketCarSetupData_V1) == 843
assert ctypes.sizeof(PacketCarTelemetryData_V1) == 1347
assert ctypes.sizeof(PacketCarStatusData_V1) == 1143
|
Module: f1_2019_telemetry.cli.recorder¶
Module f1_2019_telemetry.cli.recorder is a script that implements session data recorder functionality.
The script starts a thread to capture incoming UDP packets, and a thread to write captured UDP packets to an SQLite3 database file.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 | #! /usr/bin/env python3
"""This script captures F1 2019 telemetry packets (sent over UDP) and stores them into SQLite3 database files.
One database file will contain all packets from one session.
From UDP packet to database entry
---------------------------------
The data flow of UDP packets into the database is managed by 2 threads.
PacketReceiver thread:
(1) The PacketReceiver thread does a select() to wait on incoming packets in the UDP socket.
(2) When woken up with the notification that a UDP packet is available for reading, it is actually read from the socket.
(3) The receiver thread calls the recorder_thread.record_packet() method with a TimedPacket containing
the reception timestamp and the packet just read.
(4) The recorder_thread.record_packet() method locks its packet queue, inserts the packet there,
then unlocks the queue. Note that this method is only called from within the receiver thread!
(5) repeat from (1).
PacketRecorder thread:
(1) The PacketRecorder thread sleeps for a given period, then wakes up.
(2) It locks its packet queue, moves the queue's packets to a local variable, empties the packet queue,
then unlocks the packet queue.
(3) The packets just moved out of the queue are passed to the 'process_incoming_packets' method.
(4) The 'process_incoming_packets' method inspects the packet headers, and converts the packet data
into SessionPacket instances that are suitable for inserting into the database.
In the process, it collects packets from the same session. After collecting all
available packets from the same session, it passed them on to the
'process_incoming_same_session_packets' method.
(5) The 'process_incoming_same_session_packets' method makes sure that the appropriate SQLite database file
is opened (i.e., the one with matching sessionUID), then writes the packets into the 'packets' table.
By decoupling the packet capture and database writing in different threads, we minimize the risk of
dropping UDP packets. This risk is real because SQLite3 database commits can take a considerable time.
"""
import argparse
import sys
import time
import socket
import sqlite3
import threading
import logging
import ctypes
import selectors
from collections import namedtuple
from .threading_utils import WaitConsoleThread, Barrier
from ..packets import PacketHeader, PacketID, HeaderFieldsToPacketType, unpack_udp_packet
# The type used by the PacketReceiverThread to represent incoming telemetry packets, with timestamp.
TimestampedPacket = namedtuple('TimestampedPacket', 'timestamp, packet')
# The type used by the PacketRecorderThread to represent incoming telemetry packets for storage in the SQLite3 database.
SessionPacket = namedtuple('SessionPacket', 'timestamp, packetFormat, gameMajorVersion, gameMinorVersion, packetVersion, packetId, sessionUID, sessionTime, frameIdentifier, playerCarIndex, packet')
class PacketRecorder:
"""The PacketRecorder records incoming packets to SQLite3 database files.
A single SQLite3 file stores packets from a single session.
Whenever a new session starts, any open file is closed, and a new database file is created.
"""
# The SQLite3 query that creates the 'packets' table in the database file.
_create_packets_table_query = """
CREATE TABLE packets (
pkt_id INTEGER PRIMARY KEY, -- Alias for SQLite3's 'rowid'.
timestamp REAL NOT NULL, -- The POSIX time right after capturing the telemetry packet.
packetFormat INTEGER NOT NULL, -- Header field: packet format.
gameMajorVersion INTEGER NOT NULL, -- Header field: game major version.
gameMinorVersion INTEGER NOT NULL, -- Header field: game minor version.
packetVersion INTEGER NOT NULL, -- Header field: packet version.
packetId INTEGER NOT NULL, -- Header field: packet type ('packetId' is a bit of a misnomer).
sessionUID CHAR(16) NOT NULL, -- Header field: unique session id as hex string.
sessionTime REAL NOT NULL, -- Header field: session time.
frameIdentifier INTEGER NOT NULL, -- Header field: frame identifier.
playerCarIndex INTEGER NOT NULL, -- Header field: player car index.
packet BLOB NOT NULL -- The packet itself
);
"""
# The SQLite3 query that inserts packet data into the 'packets' table of an open database file.
_insert_packets_query = """
INSERT INTO packets(
timestamp,
packetFormat, gameMajorVersion, gameMinorVersion, packetVersion, packetId, sessionUID,
sessionTime, frameIdentifier, playerCarIndex,
packet) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
"""
def __init__(self):
self._conn = None
self._cursor = None
self._filename = None
self._sessionUID = None
def close(self):
"""Make sure that no database remains open."""
if self._conn is not None:
self._close_database()
def _open_database(self, sessionUID: str):
"""Open SQLite3 database file and make sure it has the correct schema."""
assert self._conn is None
filename = "F1_2019_{:s}.sqlite3".format(sessionUID)
logging.info("Opening file {!r}.".format(filename))
conn = sqlite3.connect(filename)
cursor = conn.cursor()
# Get rid of indentation and superfluous newlines in the 'CREATE TABLE' command.
query = "".join(line[8:] + "\n" for line in PacketRecorder._create_packets_table_query.split("\n")[1:-1])
# Try to execute the 'CREATE TABLE' statement. If it already exists, this will raise an exception.
try:
cursor.execute(query)
except sqlite3.OperationalError:
logging.info(" (Appending to existing file.)")
else:
logging.info(" (Created new file.)")
self._conn = conn
self._cursor = cursor
self._filename = filename
self._sessionUID = sessionUID
def _close_database(self):
"""Close SQLite3 database file."""
assert self._conn is not None
logging.info("Closing file {!r}.".format(self._filename))
self._cursor.close()
self._cursor = None
self._conn.close()
self._conn = None
self._filename = None
self._sessionUID = None
def _insert_and_commit_same_session_packets(self, same_session_packets):
"""Insert session packets to database and commit."""
assert self._conn is not None
self._cursor.executemany(PacketRecorder._insert_packets_query, same_session_packets)
self._conn.commit()
def _process_same_session_packets(self, same_session_packets):
"""Insert packets from the same session into the 'packets' table of the appropriate database file.
Precondition: all packets in 'same_session_packets' are from the same session (identical 'sessionUID' field).
We need to handle four different cases:
(1) 'same_session_packets' is empty:
--> return (no-op).
(2) A database file is currently open, but it stores packets with a different session UID:
--> Close database;
--> Open database with correct session UID;
--> Insert 'same_session_packets'.
(3) No database file is currently open:
--> Open database with correct session UID;
--> Insert 'same_session_packets'.
(4) A database is currently open, with correct session UID:
--> Insert 'same_session_packets'.
"""
if not same_session_packets:
# Nothing to insert.
return
if self._conn is not None and self._sessionUID != same_session_packets[0].sessionUID:
# Close database if it's recording a different session.
self._close_database()
if self._conn is None:
# Open database with the correct sessionID.
self._open_database(same_session_packets[0].sessionUID)
# Write packets.
self._insert_and_commit_same_session_packets(same_session_packets)
def process_incoming_packets(self, timestamped_packets):
"""Process incoming packets by recording them into the correct database file.
The incoming 'timestamped_packets' is a list of timestamped raw UDP packets.
We process them to a variable 'same_session_packets', which is a list of consecutive
packets having the same 'sessionUID' field. In this list, each packet is a 11-element tuple
that can be inserted into the 'packets' table of the database.
The 'same_session_packets' are then passed on to the '_process_same_session_packets'
method that writes them into the appropriate database file.
"""
t1 = time.monotonic()
# Invariant to be guaranteed: all packets in 'same_session_packets' have the same 'sessionUID' field.
same_session_packets = []
for (timestamp, packet) in timestamped_packets:
if len(packet) < ctypes.sizeof(PacketHeader):
logging.error("Dropped bad packet of size {} (too short).".format(len(packet)))
continue
header = PacketHeader.from_buffer_copy(packet)
packet_type_tuple = (header.packetFormat, header.packetVersion, header.packetId)
packet_type = HeaderFieldsToPacketType.get(packet_type_tuple)
if packet_type is None:
logging.error("Dropped unrecognized packet (format, version, id) = {!r}.".format(packet_type_tuple))
continue
if len(packet) != ctypes.sizeof(packet_type):
logging.error("Dropped packet with unexpected size; "
"(format, version, id) = {!r} packet, size = {}, expected {}.".format(
packet_type_tuple, len(packet), ctypes.sizeof(packet_type)))
continue
if header.packetId == PacketID.EVENT: # Log Event packets
event_packet = unpack_udp_packet(packet)
logging.info("Recording event packet: {}".format(event_packet.eventStringCode.decode()))
# NOTE: the sessionUID is not reliable at the start of a session (in F1 2018, need to check for F1 2019).
# See: http://forums.codemasters.com/discussion/138130/bug-f1-2018-pc-v1-0-4-udp-telemetry-bad-session-uid-in-first-few-packets-of-a-session
# Create an INSERT-able tuple for the data in this packet.
#
# Note that we convert the sessionUID to a 16-digit hex string here.
# SQLite3 can store 64-bit numbers, but only signed ones.
# To prevent any issues, we represent the sessionUID as a 16-digit hex string instead.
session_packet = SessionPacket(
timestamp,
header.packetFormat, header.gameMajorVersion, header.gameMinorVersion,
header.packetVersion, header.packetId, "{:016x}".format(header.sessionUID),
header.sessionTime, header.frameIdentifier, header.playerCarIndex,
packet
)
if len(same_session_packets) > 0 and same_session_packets[0].sessionUID != session_packet.sessionUID:
# Write 'same_session_packets' collected so far to the correct session database, then forget about them.
self._process_same_session_packets(same_session_packets)
same_session_packets.clear()
same_session_packets.append(session_packet)
# Write 'same_session_packets' to the correct session database, then forget about them.
# The 'same_session_packets.clear()' is not strictly necessary here, because 'same_session_packets' is about to
# go out of scope; but we make it explicit for clarity.
self._process_same_session_packets(same_session_packets)
same_session_packets.clear()
t2 = time.monotonic()
duration = (t2 - t1)
logging.info("Recorded {} packets in {:.3f} ms.".format(len(timestamped_packets), duration * 1000.0))
def no_packets_received(self, age: float) -> None:
"""No packets were received for a considerable time. If a database file is open, close it."""
if self._conn is None:
logging.info("No packets to record for {:.3f} seconds.".format(age))
else:
logging.info("No packets to record for {:.3f} seconds; closing file due to inactivity.".format(age))
self._close_database()
class PacketRecorderThread(threading.Thread):
"""The PacketRecorderThread writes telemetry data to SQLite3 files."""
def __init__(self, record_interval):
super().__init__(name='recorder')
self._record_interval = record_interval
self._packets = []
self._packets_lock = threading.Lock()
self._socketpair = socket.socketpair()
def close(self):
for sock in self._socketpair:
sock.close()
def run(self):
"""Receive incoming packets and hand them over the the PacketRecorder.
This method runs in its own thread.
"""
selector = selectors.DefaultSelector()
key_socketpair = selector.register(self._socketpair[0], selectors.EVENT_READ)
recorder = PacketRecorder()
packets = []
logging.info("Recorder thread started.")
quitflag = False
inactivity_timer = time.time()
while not quitflag:
# Calculate the timeout value that will bring us in sync with the next period.
timeout = (-time.time()) % self._record_interval
# If the timeout interval is too short, increase its length by 1 period.
if timeout < 0.5 * self._record_interval:
timeout += self._record_interval
for (key, events) in selector.select(timeout):
if key == key_socketpair:
quitflag = True
# Swap packets, so the 'record_packet' method can be called uninhibited as soon as possible.
with self._packets_lock:
(packets, self._packets) = (self._packets, packets)
if len(packets) != 0:
inactivity_timer = packets[-1].timestamp
recorder.process_incoming_packets(packets)
packets.clear()
else:
t_now = time.time()
age = t_now - inactivity_timer
recorder.no_packets_received(age)
inactivity_timer = t_now
recorder.close()
selector.close()
logging.info("Recorder thread stopped.")
def request_quit(self):
"""Request termination of the PacketRecorderThread.
Called from the main thread to request that we quit.
"""
self._socketpair[1].send(b'\x00')
def record_packet(self, timestamped_packet):
"""Called from the receiver thread for every UDP packet received."""
with self._packets_lock:
self._packets.append(timestamped_packet)
class PacketReceiverThread(threading.Thread):
"""The PacketReceiverThread receives incoming telemetry packets via the network and passes them to the PacketRecorderThread for storage."""
def __init__(self, udp_port, recorder_thread):
super().__init__(name='receiver')
self._udp_port = udp_port
self._recorder_thread = recorder_thread
self._socketpair = socket.socketpair()
def close(self):
for sock in self._socketpair:
sock.close()
def run(self):
"""Receive incoming packets and hand them over to the PacketRecorderThread.
This method runs in its own thread.
"""
udp_socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM)
# Allow multiple receiving endpoints.
if sys.platform in ['darwin']:
udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
elif sys.platform in ['linux', 'win32']:
udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# Accept UDP packets from any host.
address = ('', self._udp_port)
udp_socket.bind(address)
selector = selectors.DefaultSelector()
key_udp_socket = selector.register(udp_socket, selectors.EVENT_READ)
key_socketpair = selector.register(self._socketpair[0], selectors.EVENT_READ)
logging.info("Receiver thread started, reading UDP packets from port {}.".format(self._udp_port))
quitflag = False
while not quitflag:
for (key, events) in selector.select():
timestamp = time.time()
if key == key_udp_socket:
# All telemetry UDP packets fit in 2048 bytes with room to spare.
packet = udp_socket.recv(2048)
timestamped_packet = TimestampedPacket(timestamp, packet)
self._recorder_thread.record_packet(timestamped_packet)
elif key == key_socketpair:
quitflag = True
selector.close()
udp_socket.close()
for sock in self._socketpair:
sock.close()
logging.info("Receiver thread stopped.")
def request_quit(self):
"""Request termination of the PacketReceiverThread.
Called from the main thread to request that we quit.
"""
self._socketpair[1].send(b'\x00')
def main():
"""Record incoming telemetry data until the user presses enter."""
# Configure logging.
logging.basicConfig(level=logging.DEBUG, format="%(asctime)-23s | %(threadName)-10s | %(levelname)-5s | %(message)s")
logging.Formatter.default_msec_format = '%s.%03d'
# Parse command line arguments.
parser = argparse.ArgumentParser(description="Record F1 2019 telemetry data to SQLite3 files.")
parser.add_argument("-p", "--port", default=20777, type=int, help="UDP port to listen to (default: 20777)", dest='port')
parser.add_argument("-i", "--interval", default=1.0, type=float, help="interval for writing incoming data to SQLite3 file, in seconds (default: 1.0)", dest='interval')
args = parser.parse_args()
# Start recorder thread first, then receiver thread.
quit_barrier = Barrier()
recorder_thread = PacketRecorderThread(args.interval)
recorder_thread.start()
receiver_thread = PacketReceiverThread(args.port, recorder_thread)
receiver_thread.start()
wait_console_thread = WaitConsoleThread(quit_barrier)
wait_console_thread.start()
# Recorder, receiver, and wait_console threads are now active. Run until we're asked to quit.
quit_barrier.wait()
# Stop threads.
wait_console_thread.request_quit()
wait_console_thread.join()
wait_console_thread.close()
receiver_thread.request_quit()
receiver_thread.join()
receiver_thread.close()
recorder_thread.request_quit()
recorder_thread.join()
recorder_thread.close()
# All done.
logging.info("All done.")
if __name__ == "__main__":
main()
|
Module: f1_2019_telemetry.cli.player¶
Module f1_2019_telemetry.cli.player is a script that implements session data playback functionality.
The script starts a thread to read session data packets stored in a SQLite3 database file, and plays them back as UDP network packets. The speed at which playback happens can be changed by a command-line parameter.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | #! /usr/bin/env python3
"""This script reads F1 2019 telemetry packets stored in a SQLite3 database file and sends them out over UDP, effectively replaying a session of the F1 2019 game."""
import sys
import logging
import threading
import argparse
import time
import sqlite3
import socket
import selectors
from .threading_utils import WaitConsoleThread, Barrier
from ..packets import HeaderFieldsToPacketType
class PacketPlaybackThread(threading.Thread):
"""The PacketPlaybackThread reads telemetry data from an SQLite3 file and plays it back as UDP packets."""
def __init__(self, filename, destination, port, realtime_factor, quit_barrier):
super().__init__(name='playback')
self._filename = filename
self._destination = destination
self._port = port
self._realtime_factor = realtime_factor
self._quit_barrier = quit_barrier
self._packets = []
self._packets_lock = threading.Lock()
self._socketpair = socket.socketpair()
def close(self):
for sock in self._socketpair:
sock.close()
def run(self):
"""Read packets from database and replay them as UDP packets.
The run method executes in its own thread.
"""
selector = selectors.DefaultSelector()
key_socketpair = selector.register(self._socketpair[0], selectors.EVENT_READ)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
#sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if self._destination is None:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.connect(('<broadcast>', self._port))
else:
sock.connect((self._destination, self._port))
conn = sqlite3.connect(self._filename)
cursor = conn.cursor()
query = "SELECT timestamp, packet FROM packets ORDER BY pkt_id;"
cursor.execute(query)
logging.info("Playback thread started.")
packet_count = 0
quitflag = False
t_first_packet = None
t_start_playback = time.monotonic()
while not quitflag:
timestamped_packet = cursor.fetchone()
if timestamped_packet is None:
quitflag = True
continue
(timestamp, packet) = timestamped_packet
if t_first_packet is None:
t_first_packet = timestamp
t_playback = t_start_playback + (timestamp - t_first_packet) / self._realtime_factor
while True:
t_sleep = max(0.0, t_playback - time.monotonic())
for (key, events) in selector.select(t_sleep):
if key == key_socketpair:
quitflag = True
if quitflag:
break
delay = time.monotonic() - t_playback
if delay >= 0:
sock.send(packet)
packet_count += 1
if packet_count % 500 == 0:
logging.info("{} packages sent, delay: {:.3f} ms".format(packet_count, 1000.0 * delay))
break
cursor.close()
conn.close()
sock.close()
self._quit_barrier.proceed()
logging.info("playback thread stopped.")
def request_quit(self):
"""Called from the main thread to request that we quit."""
self._socketpair[1].send(b'\x00')
def main():
# Configure logging.
logging.basicConfig(level=logging.DEBUG, format="%(asctime)-23s | %(threadName)-10s | %(levelname)-5s | %(message)s")
logging.Formatter.default_msec_format = '%s.%03d'
# Parse command line arguments.
parser = argparse.ArgumentParser(description="Replay an F1 2019 session as UDP packets.")
parser.add_argument("-r", "--rtf", dest='realtime_factor', type=float, default=1.0, help="playback real-time factor (higher is faster, default=1.0)")
parser.add_argument("-d", "--destination", type=str, default=None, help="destination UDP address; omit to use broadcast (default)")
parser.add_argument("-p", "--port", type=int, default=20777, help="destination UDP port (default: 20777)")
parser.add_argument("filename", type=str, help="SQLite3 file to replay packets from")
args = parser.parse_args()
# Start threads.
quit_barrier = Barrier()
playback_thread = PacketPlaybackThread(args.filename, args.destination, args.port, args.realtime_factor, quit_barrier)
playback_thread.start()
wait_console_thread = WaitConsoleThread(quit_barrier)
wait_console_thread.start()
# Playback and wait_console threads are now active. Run until we're asked to quit.
quit_barrier.wait()
# Stop threads.
wait_console_thread.request_quit()
wait_console_thread.join()
wait_console_thread.close()
playback_thread.request_quit()
playback_thread.join()
playback_thread.close()
# All done.
logging.info("All done.")
if __name__ == "__main__":
main()
|
Module: f1_2019_telemetry.cli.monitor¶
Module f1_2019_telemetry.cli.monitor is a script that prints live session data.
The script starts a thread to capture incoming UDP packets, and outputs a summary of incoming packets.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | #! /usr/bin/env python3
"""This script monitors a UDP port for F1 2019 telemetry packets and prints useful info upon reception."""
import argparse
import sys
import socket
import threading
import logging
import selectors
import math
from .threading_utils import WaitConsoleThread, Barrier
from ..packets import PacketID, unpack_udp_packet
class PacketMonitorThread(threading.Thread):
"""The PacketMonitorThread receives incoming telemetry packets via the network and shows interesting information."""
def __init__(self, udp_port):
super().__init__(name='monitor')
self._udp_port = udp_port
self._socketpair = socket.socketpair()
self._current_frame = None
self._current_frame_data = {}
def close(self):
for sock in self._socketpair:
sock.close()
def run(self):
"""Receive incoming packets and print info about them.
This method runs in its own thread.
"""
udp_socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM)
# Allow multiple receiving endpoints.
if sys.platform in ['darwin']:
udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
elif sys.platform in ['linux', 'win32']:
udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# Accept UDP packets from any host.
address = ('', self._udp_port)
udp_socket.bind(address)
selector = selectors.DefaultSelector()
key_udp_socket = selector.register(udp_socket, selectors.EVENT_READ)
key_socketpair = selector.register(self._socketpair[0], selectors.EVENT_READ)
logging.info("Monitor thread started, reading UDP packets from port {}.".format(self._udp_port))
quitflag = False
while not quitflag:
for (key, events) in selector.select():
if key == key_udp_socket:
# All telemetry UDP packets fit in 2048 bytes with room to spare.
udp_packet = udp_socket.recv(2048)
packet = unpack_udp_packet(udp_packet)
self.process(packet)
elif key == key_socketpair:
quitflag = True
self.report()
selector.close()
udp_socket.close()
for sock in self._socketpair:
sock.close()
logging.info("Monitor thread stopped.")
def process(self, packet):
if packet.header.frameIdentifier != self._current_frame:
self.report()
self._current_frame = packet.header.frameIdentifier
self._current_frame_data = {}
self._current_frame_data[PacketID(packet.header.packetId)] = packet
def report(self):
if self._current_frame is None:
return
any_packet = next(iter(self._current_frame_data.values()))
player_car = any_packet.header.playerCarIndex
try:
distance = self._current_frame_data[PacketID.LAP_DATA].lapData[player_car].totalDistance
except:
distance = math.nan
message = "frame {:6d} distance {:10.3f}".format(self._current_frame, distance)
if message is not None:
logging.info(message)
def request_quit(self):
"""Request termination of the PacketMonitorThread.
Called from the main thread to request that we quit.
"""
self._socketpair[1].send(b'\x00')
def main():
"""Record incoming telemetry data until the user presses enter."""
# Configure logging.
logging.basicConfig(level=logging.DEBUG, format="%(asctime)-23s | %(threadName)-10s | %(levelname)-5s | %(message)s")
logging.Formatter.default_msec_format = '%s.%03d'
# Parse command line arguments.
parser = argparse.ArgumentParser(description="Monitor UDP port for incoming F1 2019 telemetry data and print information.")
parser.add_argument("-p", "--port", default=20777, type=int, help="UDP port to listen to (default: 20777)", dest='port')
args = parser.parse_args()
# Start recorder thread first, then receiver thread.
quit_barrier = Barrier()
monitor_thread = PacketMonitorThread(args.port)
monitor_thread.start()
wait_console_thread = WaitConsoleThread(quit_barrier)
wait_console_thread.start()
# Monitor and wait_console threads are now active. Run until we're asked to quit.
quit_barrier.wait()
# Stop threads.
wait_console_thread.request_quit()
wait_console_thread.join()
wait_console_thread.close()
monitor_thread.request_quit()
monitor_thread.join()
monitor_thread.close()
# All done.
logging.info("All done.")
if __name__ == "__main__":
main()
|