diff --git a/.gitignore b/.gitignore index e1c7a44..2575f4c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ *.exe *.o *.so +*.pyc # Logs and databases # ###################### diff --git a/Python/src/xplaneConnect.py b/Python/src/xplaneConnect.py new file mode 100644 index 0000000..69ab40b --- /dev/null +++ b/Python/src/xplaneConnect.py @@ -0,0 +1,316 @@ +import socket +import struct + +class XPlaneConnect(object): + '''XPlaneConnect (XPC) facilitates communication to and from the XPCPlugin.''' + socket = None + + # Basic Functions + def __init__(self, xpHost = 'localhost', xpPort = 49009, port = 0, timeout = 100): + '''Sets up a new connection to an X-Plane Connect plugin running in X-Plane. + + Args: + xpHost: The hostname of the machine running X-Plane. + xpPort: The port on which the XPC plugin is listening. Usually 49007. + port: The port which will be used to send and receive data. + timeout: The period (in milliseconds) after which read attempts will fail. + ''' + + # Validate parameters + xpIP = None + try: + xpIP = socket.gethostbyname(xpHost) + except: + raise ValueError("Unable to resolve xpHost.") + + if xpPort < 0 or xpPort > 65535: + raise ValueError("The specified X-Plane port is not a valid port number.") + if port < 0 or port > 65535: + raise ValueError("The specified port is not a valid port number.") + if timeout < 0: + raise ValueError("timeout must be non-negative.") + + # Setup XPlane IP and port + self.xpDst = (xpIP, xpPort) + + # Create and bind socket + clientAddr = ("0.0.0.0", port) + self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + self.socket.bind(clientAddr) + timeout /= 1000.0 + self.socket.settimeout(timeout) + + def __del__(self): + self.close() + + def close(self): + '''Closes the specified connection and releases resources associated with it.''' + if self.socket is not None: + self.socket.close() + self.socket = None + + def sendUDP(self, buffer): + '''Sends a message over the underlying UDP socket.''' + # Preconditions + if(len(buffer) == 0): + raise ValueError("sendUDP: buffer is empty.") + + self.socket.sendto(buffer, 0, self.xpDst) + + def readUDP(self): + '''Reads a message from the underlying UDP socket.''' + return self.socket.recv(16384) + + # Configuration + def setCONN(self, port): + '''Sets the port on which the client sends and receives data. + + Args: + port: The new port to use. + ''' + #Validate parameters + if port < 0 or port > 65535: + raise ValueError("The specified port is not a valid port number.") + + buffer = struct.pack("<4sxH", "CONN", port) + self.sendUDP(buffer) + + def pauseSim(self, pause): + '''Pauses or un-pauses the physics simulation engine in X-Plane. + + Args: + pause: True to pause the simulation; False to resume. + ''' + pause_val = 0 + if pause: + pause_val = 1 + + buffer = struct.pack("<4sxB", "SIMU", pause_val) + self.sendUDP(buffer) + + # X-Plane UDP Data + def readDATA(self): + '''Reads X-Plane data. + + Returns: A 2 dimensional array containing 0 or more rows of data. Each array + in the result will have 9 elements, the first of which is the row number which + that array represents data for, and the rest of which are the data elements in + that row. + ''' + buffer = self.readUDP(); + if len(buffer) < 6: + return None + rows = (len(buffer) - 5) / 36 + data = [] + for i in range(rows): + data.append(struct.unpack_from("9f", buffer, 5 + 36*i)) + return data + + def sendDATA(self, data): + '''Sends X-Plane data over the underlying UDP socket. + + Args: + data: An array of values representing data rows to be set. Each array in `data` + should have 9 elements, the first of which is a row number in the range (0-134), + and the rest of which are the values to set for that data row. + ''' + if len(data) > 134: + raise ValueError("Too many rows in data.") + + buffer = struct.pack("<4sxB", "DATA", len(data)) + for row in data: + if len(row) != 9: + raise ValueError("Row does not contain exactly 9 values. <" + str(row) + ">") + buffer += struct.pack(" 7: + raise ValueError("Must have between 0 and 7 items in values.") + if ac < 0 or ac > 20: + raise ValueError("Aircraft number must be between 0 and 20.") + + # Pack message + buffer = struct.pack("<4sxB", "POSI", ac) + for i in range(7): + val = -998 + if i < len(values): + val = values[i] + buffer += struct.pack(" 6: + raise ValueError("Must have between 0 and 6 items in values.") + if ac < 0 or ac > 20: + raise ValueError("Aircraft number must be between 0 and 20.") + + # Pack message + buffer = struct.pack("<4sx", "CTRL") + for i in range(6): + val = -998 + if i < len(values): + val = values[i] + if i == 4: + val = -1 if val == -998 else val + buffer += struct.pack("B", val) + else: + buffer += struct.pack(" 255: + raise ValueError("dref must be a non-empty string less than 256 characters.") + if values == None: + raise ValueError("values must be a scalar or sequence of floats.") + + # Pack message + fmt = "" + buffer = None + if hasattr(values, "__len__"): + if len(values) > 255: + raise ValueError("values must have less than 256 items.") + fmt = "<4sxB{0:d}sB{1:d}f".format(len(dref), len(values)) + buffer = struct.pack(fmt, "DREF", len(dref), dref, len(values), values) + else: + fmt = "<4sxB{0:d}sBf".format(len(dref)) + buffer = struct.pack(fmt, "DREF", len(dref), dref, 1, values) + + # Send + self.sendUDP(buffer) + + def getDREF(self, dref): + '''Gets the value of an X-Plane dataref. + + Args: + dref: The name of the dataref to get. + + Returns: A sequence of data representing the values of the requested dataref. + ''' + return self.getDREFs([dref])[0] + + def getDREFs(self, drefs): + '''Gets the value of one or more X-Plane datarefs. + + Args: + drefs: The names of the datarefs to get. + + Returns: A multidimensional sequence of data representing the values of the requested + datarefs. + ''' + # Send request + buffer = struct.pack("<4sxB", "GETD", len(drefs)) + for dref in drefs: + fmt = " 3: + raise ValueError("Invalid operation specified.") + if len(points) % 3 != 0: + raise ValueError("Invalid points. Points should be divisible by 3.") + if len(points) / 3 > 255: + raise ValueError("Too many points. You can only send 255 points at a time.") + + if op == 3: + buffer = struct.pack("<4sxBB", "WYPT", 3, 0) + else: + buffer = struct.pack("<4sxBB" + str(len(points)) + "f", "WYPT", op, len(points), *points) + self.sendUDP(buffer) diff --git a/Python/xplaneConnect.pyproj b/Python/xplaneConnect.pyproj new file mode 100644 index 0000000..bc9ff8a --- /dev/null +++ b/Python/xplaneConnect.pyproj @@ -0,0 +1,49 @@ + + + + Debug + 2.0 + 3c7a940d-17c8-4e91-882f-9bc8b1d2f54b + . + src/XPlaneConnect.py + + + . + . + XPlaneConnect + XPlaneConnect + {9a7a9026-48c1-4688-9d5d-e5699d47d074} + 2.7 + + + true + false + + + true + false + + + + + + + + + + + + 10.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\Python Tools\Microsoft.PythonTools.targets + + + + + + + + + + \ No newline at end of file diff --git a/Python/xplaneConnect.sln b/Python/xplaneConnect.sln new file mode 100644 index 0000000..862da8d --- /dev/null +++ b/Python/xplaneConnect.sln @@ -0,0 +1,24 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 2013 +VisualStudioVersion = 12.0.31101.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{888888A0-9F3D-457C-B088-3A5042F75D52}") = "XPlaneConnect", "XPlaneConnect.pyproj", "{3C7A940D-17C8-4E91-882F-9BC8B1D2F54B}" +EndProject +Project("{888888A0-9F3D-457C-B088-3A5042F75D52}") = "Tests", "..\TestScripts\Python Tests\Tests.pyproj", "{6931EBB2-4E01-4C5A-86B6-668C0E75051B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3C7A940D-17C8-4E91-882F-9BC8B1D2F54B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C7A940D-17C8-4E91-882F-9BC8B1D2F54B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6931EBB2-4E01-4C5A-86B6-668C0E75051B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6931EBB2-4E01-4C5A-86B6-668C0E75051B}.Release|Any CPU.ActiveCfg = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/TestScripts/Python Tests/Tests.py b/TestScripts/Python Tests/Tests.py new file mode 100644 index 0000000..a3ea597 --- /dev/null +++ b/TestScripts/Python Tests/Tests.py @@ -0,0 +1,298 @@ +import random +import unittest +import imp +import time + +xpc = imp.load_source('xplaneConnect', '../../Python/src/xplaneConnect.py') + +class XPCTests(unittest.TestCase): + """Tests the functionality of the XPlaneConnect class.""" + + def test_init(self): + try: + client = xpc.XPlaneConnect() + except: + self.fail("Default constructor failed.") + + try: + client = xpc.XPlaneConnect("I'm not a real host") + self.fail("Failed to catch invalid XP host.") + except ValueError: + pass + + try: + client = xpc.XPlaneConnect("127.0.0.1", 90001) + self.fail("Failed to catch invalid XP port.") + except ValueError: + pass + try: + client = xpc.XPlaneConnect("127.0.0.1", -1) + self.fail("Failed to catch invalid XP port.") + except ValueError: + pass + + try: + client = xpc.XPlaneConnect("127.0.0.1", 49009, 90001) + self.fail("Failed to catch invalid local port.") + except ValueError: + pass + try: + client = xpc.XPlaneConnect("127.0.0.1", 49009, -1) + self.fail("Failed to catch invalid XP port.") + except ValueError: + pass + + try: + client = xpc.XPlaneConnect("127.0.0.1", 49009, 0, -1) + self.fail("Failed to catch invalid timeout.") + except ValueError: + pass + + def test_close(self): + client = xpc.XPlaneConnect("127.0.0.1", 49009, 49063) + client.close() + self.assertIsNone(client.socket) + client = xpc.XPlaneConnect("127.0.0.1", 49009, 49063) + + def test_send_read(self): + # Init + test = "\x00\x01\x02\x03\x05" + + # Setup + sender = xpc.XPlaneConnect("127.0.0.1", 49063, 49064) + receiver = xpc.XPlaneConnect("127.0.0.1", 49009, 49063) + + # Execution + sender.sendUDP(test) + buf = receiver.readUDP() + + # Cleanup + sender.close() + receiver.close() + + # Tests + for a, b in zip(test, buf): + self.assertEqual(a, b) + + def test_getDREFs(self): + # Setup + client = xpc.XPlaneConnect() + drefs = ["sim/cockpit/switches/gear_handle_status",\ + "sim/cockpit2/switches/panel_brightness_ratio"] + + # Execution + result = client.getDREFs(drefs) + + # Cleanup + client.close() + + # Tests + self.assertEqual(2, len(result)) + self.assertEqual(1, len(result[0])) + self.assertEqual(4, len(result[1])) + + def test_sendDREF(self): + dref = "sim/cockpit/switches/gear_handle_status" + value = None + def do_test(): + # Setup + client = xpc.XPlaneConnect() + + # Execute + client.sendDREF(dref, value) + result = client.getDREF(dref) + + # Cleanup + client.close() + + # Tests + self.assertEqual(1, len(result)) + self.assertEqual(value, result[0]) + + # Test 1 + value = 1 + do_test() + + # Test 2 + value = 0 + do_test() + + def test_sendDATA(self): + # Setup + dref = "sim/aircraft/parts/acf_gear_deploy" + data = [[ 14, 1, 0, -998, -998, -998, -998, -998, -998 ]] + client = xpc.XPlaneConnect() + + # Execute + client.sendDATA(data) + result = client.getDREF(dref) + + # Cleanup + client.close() + + #Tests + self.assertEqual(result[0], data[0][1]) + + def test_pauseSim(self): + dref = "sim/operation/override/override_planepath" + value = None + expected = None + def do_test(): + # Setup + client = xpc.XPlaneConnect() + + # Execute + client.pauseSim(value) + result = client.getDREF(dref) + + # Cleanup + client.close() + + # Test + self.assertAlmostEqual(expected, result[0]) + + # Test 1 + value = True + expected = 1.0 + do_test() + + # Test 2 + value = False + expected = 0.0 + do_test() + + + def test_sendCTRL(self): + # Setup + drefs = ["sim/cockpit2/controls/yoke_pitch_ratio",\ + "sim/cockpit2/controls/yoke_roll_ratio",\ + "sim/cockpit2/controls/yoke_heading_ratio",\ + "sim/flightmodel/engine/ENGN_thro",\ + "sim/cockpit/switches/gear_handle_status",\ + "sim/flightmodel/controls/flaprqst"] + ctrl = [] + + def do_test(): + client = xpc.XPlaneConnect() + + # Execute + client.sendCTRL(ctrl) + result = client.getDREFs(drefs) + + # Cleanup + client.close() + + # Tests + self.assertEqual(6, len(result)) + for i in range(6): + self.assertAlmostEqual(ctrl[i], result[i][0], 4) + + # Test 1 + ctrl = [ -1.0, -1.0, -1.0, 0.0, 1.0, 1.0 ] + do_test() + + # Test 2 + ctrl = [ 1.0, 1.0, 1.0, 0.0, 1.0, 0.5 ] + do_test() + + # Test 2 + ctrl = [ 0.0, 0.0, 0.0, 0.8, 1.0, 0.0 ] + do_test() + + def test_sendPOSI(self): + # Setup + drefs = ["sim/flightmodel/position/latitude",\ + "sim/flightmodel/position/longitude",\ + "sim/flightmodel/position/elevation",\ + "sim/flightmodel/position/theta",\ + "sim/flightmodel/position/phi",\ + "sim/flightmodel/position/psi",\ + "sim/cockpit/switches/gear_handle_status"] + posi = None + def do_test(): + client = xpc.XPlaneConnect() + + # Execute + client.pauseSim(True) + client.sendPOSI(posi) + result = client.getDREFs(drefs) + client.pauseSim(False) + + # Cleanup + client.close() + + # Tests + self.assertEqual(7, len(result)) + for i in range(7): + self.assertAlmostEqual(posi[i], result[i][0], 4) + + # Test 1 + posi = [ 37.524, -122.06899, 2500, 5, 7, 11, 1 ] + do_test() + + # Test 2 + posi = [ 38, -121.0, 2000, -10, 0, 0, 0 ] + do_test() + + def test_sendTEXT(self): + # Setup + client = xpc.XPlaneConnect() + x = 200 + y = 700 + msg = "Python sendTEXT test message." + + # Execution + client.sendTEXT(msg, x, y) + # NOTE: Manually verify that msg appears on the screen in X-Plane + + # Cleanup + client.close() + + def test_sendWYPT(self): + # Setup + client = xpc.XPlaneConnect() + points = [\ + 37.5245, -122.06899, 2500,\ + 37.455397, -122.050037, 2500,\ + 37.469567, -122.051411, 2500,\ + 37.479376, -122.060509, 2300,\ + 37.482237, -122.076130, 2100,\ + 37.474881, -122.087288, 1900,\ + 37.467660, -122.079391, 1700,\ + 37.466298, -122.090549, 1500,\ + 37.362562, -122.039223, 1000,\ + 37.361448, -122.034416, 1000,\ + 37.361994, -122.026348, 1000,\ + 37.365541, -122.022572, 1000,\ + 37.373727, -122.024803, 1000,\ + 37.403869, -122.041283, 50,\ + 37.418544, -122.049222, 6] + + # Execution + client.sendPOSI([37.5245, -122.06899, 2500]) + client.sendWYPT(3, []) + client.sendWYPT(1, points) + # NOTE: Manually verify that points appear on the screen in X-Plane + + # Cleanup + client.close() + + def test_setCONN(self): + # Setup + dref = "sim/cockpit/switches/gear_handle_status"; + client = xpc.XPlaneConnect() + + # Execute + client.setCONN(49055) + result = client.getDREF(dref) + + # Cleanup + client.close() + + # Test + self.assertEqual(1, len(result)) + + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/TestScripts/Python Tests/Tests.pyproj b/TestScripts/Python Tests/Tests.pyproj new file mode 100644 index 0000000..d39f8ab --- /dev/null +++ b/TestScripts/Python Tests/Tests.pyproj @@ -0,0 +1,46 @@ + + + + Debug + 2.0 + 6931ebb2-4e01-4c5a-86b6-668c0e75051b + . + Tests.py + + + . + . + Tests + Tests + {9a7a9026-48c1-4688-9d5d-e5699d47d074} + 2.7 + + + true + false + + + true + false + + + + + + + + + 10.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\Python Tools\Microsoft.PythonTools.targets + + + + + + + + + + \ No newline at end of file