|
|
@@ -0,0 +1,437 @@
|
|
|
+from concurrent.futures import thread
|
|
|
+
|
|
|
+import gi
|
|
|
+gi.require_version('Gtk', '4.0')
|
|
|
+from gi.repository import Gtk, GLib, Gdk
|
|
|
+
|
|
|
+import threading
|
|
|
+from inputs import get_gamepad
|
|
|
+import math
|
|
|
+
|
|
|
+# Importowanie stylów CSS
|
|
|
+css_provider = Gtk.CssProvider()
|
|
|
+css_provider.load_from_path('style.css')
|
|
|
+Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
|
|
|
+
|
|
|
+# Funkcja pomocnicza
|
|
|
+def setMarginAll(widget, margin):
|
|
|
+ widget.set_margin_top(margin)
|
|
|
+ widget.set_margin_bottom(margin)
|
|
|
+ widget.set_margin_start(margin)
|
|
|
+ widget.set_margin_end(margin)
|
|
|
+
|
|
|
+class DroneApplication(Gtk.ApplicationWindow):
|
|
|
+
|
|
|
+ def __init__(self, application):
|
|
|
+ super().__init__(application=application, title="cx.copter")
|
|
|
+
|
|
|
+ self.set_default_size(1280, 960)
|
|
|
+ settings = Gtk.Settings.get_default()
|
|
|
+ settings.set_property("gtk-application-prefer-dark-theme", True)
|
|
|
+
|
|
|
+ # Domyślne wartości dla zmiennych przechowujących stan gamepada
|
|
|
+ self.gamepad_connected = False
|
|
|
+ self.right_x = 0
|
|
|
+ self.right_y = 0
|
|
|
+ self.left_y = 0
|
|
|
+ self.flaps_zl = 0
|
|
|
+ self.flaps_zr = 0
|
|
|
+ self.flaps_z = 0
|
|
|
+ self.toggle = False
|
|
|
+ self.prev = 1
|
|
|
+
|
|
|
+ # Inne zmienne
|
|
|
+ self.ZAKRES = 10 # Jaki zakres [-n; n] mają mieć sygnały wejścia gamepada.
|
|
|
+ self.PITCH_STEPS = 8 # Ile ma być łącznie szczebli głównych na połowie sztucznego horyzontu.
|
|
|
+ self.PITCH_DEG_STEP = 15 # Co ile stopni rysować szczebel główny w sztucznym horyzoncie.
|
|
|
+
|
|
|
+ # Uruchomienie wątku do monitorowania gamepada
|
|
|
+ thread = threading.Thread(target=self.monitor_controller, daemon=True)
|
|
|
+ thread.start()
|
|
|
+
|
|
|
+ # INTERFEJS UŻYTKOWNIKA
|
|
|
+ main = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
|
+
|
|
|
+ # Nagłówek
|
|
|
+ logo = Gtk.Image.new_from_file("assets/logo.png")
|
|
|
+ logo.props.pixel_size = 64
|
|
|
+
|
|
|
+ title = Gtk.Label(label = "Aplikacja do sterowania dronem")
|
|
|
+ title.add_css_class("title-1")
|
|
|
+
|
|
|
+ batteryBar = Gtk.ProgressBar()
|
|
|
+ batteryBar.set_fraction(0.5)
|
|
|
+ batteryBar.set_text("Bateria drona: NULL%")
|
|
|
+ batteryBar.set_show_text(True)
|
|
|
+ batteryBar.set_valign(Gtk.Align.CENTER)
|
|
|
+ batteryBar.set_css_classes(['battery-bar'])
|
|
|
+
|
|
|
+ header = Gtk.CenterBox(
|
|
|
+ start_widget=logo,
|
|
|
+ center_widget=title,
|
|
|
+ end_widget=batteryBar
|
|
|
+ )
|
|
|
+
|
|
|
+ setMarginAll(logo, 10)
|
|
|
+ setMarginAll(batteryBar, 10)
|
|
|
+
|
|
|
+ main.append(header)
|
|
|
+
|
|
|
+ main.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
|
|
|
+
|
|
|
+ # Ciało
|
|
|
+ body = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, homogeneous=True, spacing=15)
|
|
|
+
|
|
|
+ self.sliderAltitudeBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) # sterowanie wysokością (lewy joystick)
|
|
|
+
|
|
|
+ self.sliderAltitudeLabel = Gtk.Label(label=f"ALT: {self.left_y}")
|
|
|
+
|
|
|
+ self.sliderAltitude = Gtk.Scale(orientation=Gtk.Orientation.VERTICAL)
|
|
|
+ self.sliderAltitude.set_range(-self.ZAKRES, self.ZAKRES)
|
|
|
+ self.sliderAltitude.set_sensitive(False)
|
|
|
+ self.sliderAltitude.set_inverted(True)
|
|
|
+ self.sliderAltitude.set_size_request(54, 250)
|
|
|
+ self.sliderAltitude.set_vexpand(True)
|
|
|
+
|
|
|
+ self.sliderAltitudeBox.append(self.sliderAltitudeLabel)
|
|
|
+ self.sliderAltitudeBox.append(self.sliderAltitude)
|
|
|
+
|
|
|
+ self.movementAreaBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) # sterowanie poruszaniem się (prawy joystick)
|
|
|
+
|
|
|
+ self.movementAreaLabel = Gtk.Label(label=f"PORUSZANIE")
|
|
|
+
|
|
|
+ self.movementArea = Gtk.DrawingArea()
|
|
|
+ self.movementArea.set_hexpand(True)
|
|
|
+ self.movementArea.set_vexpand(True)
|
|
|
+ self.movementArea.set_draw_func(self.draw_movement)
|
|
|
+
|
|
|
+ self.movementAreaBox.append(self.movementAreaLabel)
|
|
|
+ self.movementAreaBox.append(self.movementArea)
|
|
|
+
|
|
|
+ self.sliderZAxisBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) # sterowanie poruszaniem po osi Z (klapy)
|
|
|
+
|
|
|
+ self.sliderZAxisLabel = Gtk.Label(label=f"OBRACANIE: {self.flaps_z}")
|
|
|
+
|
|
|
+ self.sliderZAxis = Gtk.Scale()
|
|
|
+ self.sliderZAxis.set_range(-self.ZAKRES, self.ZAKRES)
|
|
|
+ self.sliderZAxis.set_sensitive(False)
|
|
|
+
|
|
|
+ self.sliderZAxisBox.append(self.sliderZAxisLabel)
|
|
|
+ self.sliderZAxisBox.append(self.sliderZAxis)
|
|
|
+
|
|
|
+ self.horizon = Gtk.DrawingArea() # odczyt z żyroskopu - sztuczny horyzont
|
|
|
+ self.horizon.set_hexpand(True)
|
|
|
+ self.horizon.set_vexpand(True)
|
|
|
+ self.horizon.set_draw_func(self.draw_horizon)
|
|
|
+
|
|
|
+ horizonFrame = Gtk.AspectFrame( xalign=0.5, yalign=0.5, ratio=1.0, obey_child=False )
|
|
|
+ horizonFrame.set_child(self.horizon)
|
|
|
+
|
|
|
+ self.sliderAltitude.set_css_classes(['grid-elements'])
|
|
|
+ self.movementArea.set_css_classes(['grid-elements'])
|
|
|
+ self.sliderZAxis.set_css_classes(['grid-elements'])
|
|
|
+ self.horizon.set_css_classes(['grid-elements'])
|
|
|
+
|
|
|
+ leftPanelGrid = Gtk.Grid(hexpand=True)
|
|
|
+ leftPanelGrid.attach(self.sliderAltitudeBox, 0, 0, 1, 1)
|
|
|
+ leftPanelGrid.attach(self.movementAreaBox, 1, 0, 1, 1)
|
|
|
+ leftPanelGrid.attach(self.sliderZAxisBox, 0, 1, 2, 1)
|
|
|
+
|
|
|
+ body.append(leftPanelGrid)
|
|
|
+ body.append(horizonFrame)
|
|
|
+ setMarginAll(body, 20)
|
|
|
+
|
|
|
+ main.append(body)
|
|
|
+ main.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
|
|
|
+
|
|
|
+ # Stopka
|
|
|
+ footer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
|
|
+ footer.set_size_request(-1, 150)
|
|
|
+ setMarginAll(footer, 20)
|
|
|
+
|
|
|
+ diagnosticBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
|
+ diagnosticLabel = Gtk.Label(label="DIAGNOSTYKA", halign=Gtk.Align.CENTER)
|
|
|
+ diagnosticLabel.add_css_class("title-4")
|
|
|
+ diagnosticBox.append(diagnosticLabel)
|
|
|
+
|
|
|
+ self.droneStatusLabel = Gtk.Label(label="Dron: Not Found")
|
|
|
+ self.droneStatusLabel.set_halign(Gtk.Align.START)
|
|
|
+ self.droneStatusLabel.set_size_request(180, -1)
|
|
|
+ self.droneStatusLabel.set_halign(Gtk.Align.CENTER)
|
|
|
+ self.droneStatusLabel.set_css_classes(['status-not-ok'])
|
|
|
+
|
|
|
+ self.gamepadStatusLabel = Gtk.Label(label="Gamepad: Not Found")
|
|
|
+ self.gamepadStatusLabel.set_halign(Gtk.Align.START)
|
|
|
+ self.gamepadStatusLabel.set_size_request(180, -1)
|
|
|
+ self.gamepadStatusLabel.set_css_classes(['status-inaccessible'])
|
|
|
+
|
|
|
+ self.motorsStatusButton = Gtk.Button(label="Silniki: Off", sensitive=False)
|
|
|
+ self.motorsStatusButton.set_halign(Gtk.Align.START)
|
|
|
+ self.motorsStatusButton.set_size_request(180, -1)
|
|
|
+ self.motorsStatusButton.set_css_classes(['status-inaccessible'])
|
|
|
+
|
|
|
+ diagnosticBox.append(self.droneStatusLabel)
|
|
|
+ diagnosticBox.append(self.gamepadStatusLabel)
|
|
|
+ diagnosticBox.append(self.motorsStatusButton)
|
|
|
+
|
|
|
+ logsBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=15, homogeneous=True) # Telemetria
|
|
|
+ logsBox.set_hexpand(True)
|
|
|
+
|
|
|
+ sentBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
|
+ sentLabel = Gtk.Label(label="WYSŁANE:")
|
|
|
+ sentBox.append(sentLabel)
|
|
|
+
|
|
|
+ sentScroll = Gtk.ScrolledWindow()
|
|
|
+ sentScroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
|
|
+ sentScroll.set_vexpand(True)
|
|
|
+ sentScroll.set_size_request(150, 40)
|
|
|
+ self.sent_logs = Gtk.TextView()
|
|
|
+ self.sent_logs.set_editable(False)
|
|
|
+ self.sent_logs.set_css_classes(['grid-elements'])
|
|
|
+ sentScroll.set_child(self.sent_logs)
|
|
|
+
|
|
|
+ sentBox.append(sentScroll)
|
|
|
+
|
|
|
+ recvBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
|
+ recvLabel = Gtk.Label(label="ODEBRANE:")
|
|
|
+ recvBox.append(recvLabel)
|
|
|
+
|
|
|
+ recvScroll = Gtk.ScrolledWindow()
|
|
|
+ recvScroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
|
|
+ recvScroll.set_vexpand(True)
|
|
|
+ recvScroll.set_size_request(150, 40)
|
|
|
+ self.recv_logs = Gtk.TextView()
|
|
|
+ self.recv_logs.set_editable(False)
|
|
|
+ self.recv_logs.set_css_classes(['grid-elements'])
|
|
|
+ recvScroll.set_child(self.recv_logs)
|
|
|
+
|
|
|
+ recvBox.append(recvScroll)
|
|
|
+
|
|
|
+ logsBox.append(sentBox)
|
|
|
+ logsBox.append(recvBox)
|
|
|
+
|
|
|
+ actionsBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) # Przyciski
|
|
|
+ actionsBox.set_valign(Gtk.Align.CENTER)
|
|
|
+
|
|
|
+ self.btn_test = Gtk.Button(label="Test LED / Silników")
|
|
|
+ self.btn_test.set_size_request(150, -1)
|
|
|
+
|
|
|
+ self.btn_cali = Gtk.Button(label="Kalibracja Żyroskopu")
|
|
|
+ self.btn_cali.set_size_request(150, -1)
|
|
|
+
|
|
|
+ actionsBox.append(self.btn_test)
|
|
|
+ actionsBox.append(self.btn_cali)
|
|
|
+
|
|
|
+ footer.append(diagnosticBox)
|
|
|
+ footer.append(logsBox)
|
|
|
+ footer.append(actionsBox)
|
|
|
+
|
|
|
+ main.append(footer)
|
|
|
+
|
|
|
+
|
|
|
+ self.set_child(main)
|
|
|
+
|
|
|
+ # Funkcja do rysowania kierunku poruszania
|
|
|
+ def draw_movement(self, area, c, width, height):
|
|
|
+ cx = width / 2
|
|
|
+ cy = height / 2
|
|
|
+
|
|
|
+ target_x = cx + ((self.right_x / self.ZAKRES) * cx)
|
|
|
+ target_y = cy - ((self.right_y / self.ZAKRES) * cy)
|
|
|
+
|
|
|
+ # Linia
|
|
|
+ c.set_source_rgb(1, 0, 0)
|
|
|
+ c.move_to(cx, cy)
|
|
|
+ c.line_to(target_x, target_y)
|
|
|
+ c.stroke()
|
|
|
+
|
|
|
+ # Kropka
|
|
|
+ c.set_source_rgb(1, 0, 0)
|
|
|
+ c.arc(target_x, target_y, 4, 0, 2 * math.pi)
|
|
|
+ c.fill()
|
|
|
+
|
|
|
+ # Tekst
|
|
|
+ c.set_source_rgb(1, 1, 1)
|
|
|
+ c.move_to(cx, cy)
|
|
|
+ c.set_font_size(12)
|
|
|
+ c.show_text(f" {self.right_x} | {self.right_y}")
|
|
|
+ c.move_to(target_x, target_y)
|
|
|
+
|
|
|
+ # Funkcja do rysowania sztucznego horyzontu
|
|
|
+ def draw_horizon(self, area, c, width, height):
|
|
|
+ cx = width / 2.0
|
|
|
+ cy = height / 2.0
|
|
|
+
|
|
|
+ # DLA CELÓW SYMULACJI
|
|
|
+ max_pitch = 45
|
|
|
+ max_roll = 45
|
|
|
+
|
|
|
+ pixels_per_step = cy - (cy + (height / self.PITCH_STEPS))
|
|
|
+
|
|
|
+ roll_angle = (self.right_x / self.ZAKRES) * max_roll
|
|
|
+ pitch_offset = (self.right_y / self.ZAKRES) * max_pitch
|
|
|
+
|
|
|
+ pitch_final = pixels_per_step * (pitch_offset / self.PITCH_DEG_STEP)
|
|
|
+
|
|
|
+ c.save()
|
|
|
+ c.translate(cx, cy)
|
|
|
+ c.rotate(-math.radians(roll_angle))
|
|
|
+ c.translate(0, pitch_final)
|
|
|
+ c.translate(-cx, -cy)
|
|
|
+
|
|
|
+ # Rysowanie tarczy
|
|
|
+ c.set_source_rgb(0.14, 0.63, 1) # niebo
|
|
|
+ c.rectangle(-width, -height, width*4, height*2)
|
|
|
+ c.fill()
|
|
|
+
|
|
|
+ c.set_source_rgb(0.55, 0.38, 0.22) # ziemia
|
|
|
+ c.rectangle(-width, cy, width*4, height*2)
|
|
|
+ c.fill()
|
|
|
+
|
|
|
+ # Rysowanie linii
|
|
|
+ c.set_source_rgb(1, 1, 1)
|
|
|
+ c.set_line_width(2)
|
|
|
+ c.move_to(-cx, cy)
|
|
|
+ c.line_to(width*2, cy)
|
|
|
+ c.stroke()
|
|
|
+
|
|
|
+ for x in range(-self.PITCH_STEPS, self.PITCH_STEPS + 1):
|
|
|
+ if x == 0: continue # pomijamy środkową kreskę
|
|
|
+
|
|
|
+ # główna linia
|
|
|
+ target_pitch_y = cy + ((height / self.PITCH_STEPS) * x)
|
|
|
+
|
|
|
+ c.set_source_rgb(1, 1, 1)
|
|
|
+ c.set_line_width(2)
|
|
|
+ c.move_to(cx / 2, target_pitch_y)
|
|
|
+ c.line_to((cx / 2) + cx, target_pitch_y)
|
|
|
+ c.stroke()
|
|
|
+
|
|
|
+ pitch_text = str(-x * self.PITCH_DEG_STEP)
|
|
|
+ c.set_font_size(max(10, cx * 0.06))
|
|
|
+ text_width = c.text_extents(pitch_text).width
|
|
|
+
|
|
|
+ c.move_to(cx / 2, target_pitch_y - 5)
|
|
|
+ c.show_text(pitch_text)
|
|
|
+ c.move_to((cx / 2) + cx - text_width, target_pitch_y - 5)
|
|
|
+ c.show_text(pitch_text)
|
|
|
+
|
|
|
+ # pomocnicza linia
|
|
|
+ kierunek = 1 if x > 0 else -1
|
|
|
+ target_pitch_y_sub = cy + ((height / self.PITCH_STEPS) * (x - 0.5 * kierunek))
|
|
|
+
|
|
|
+ c.set_source_rgb(1, 1, 1)
|
|
|
+ c.set_line_width(2)
|
|
|
+ c.move_to(cx - (cx / 4), target_pitch_y_sub)
|
|
|
+ c.line_to(cx + (cx / 4), target_pitch_y_sub)
|
|
|
+ c.stroke()
|
|
|
+
|
|
|
+ c.restore()
|
|
|
+
|
|
|
+ # Rysowanie dziobu
|
|
|
+ c.set_source_rgb(1, 1, 0)
|
|
|
+ scalar = cx * 0.08
|
|
|
+ c.set_line_width(max(2.0, scalar * 0.2))
|
|
|
+
|
|
|
+ c.move_to(cx - 6 * scalar, cy)
|
|
|
+ c.line_to(cx - 2 * scalar, cy)
|
|
|
+ c.line_to(cx - 1 * scalar, cy + 1 * scalar)
|
|
|
+ c.line_to(cx, cy)
|
|
|
+ c.line_to(cx + 1 * scalar, cy + 1 * scalar)
|
|
|
+ c.line_to(cx + 2 * scalar, cy)
|
|
|
+ c.line_to(cx + 6 * scalar, cy)
|
|
|
+ c.stroke()
|
|
|
+
|
|
|
+ c.arc(cx, cy, max(2.0, scalar * 0.15), 0, 2 * math.pi)
|
|
|
+ c.fill()
|
|
|
+
|
|
|
+ # Funkcja do aktualizacji interfejsu użytkownika z aktualnymi wartościami stanu gamepada
|
|
|
+ def update_ui(self):
|
|
|
+ # Input gamepada
|
|
|
+ self.sliderAltitude.set_value(self.left_y)
|
|
|
+ self.sliderAltitudeLabel.set_text(f"ALT: {self.left_y}")
|
|
|
+ self.movementArea.queue_draw()
|
|
|
+ self.movementAreaLabel.set_text(f"PORUSZANIE")
|
|
|
+ self.sliderZAxis.set_value(self.flaps_z)
|
|
|
+ self.sliderZAxisLabel.set_text(f"OBRACANIE: {self.flaps_z}")
|
|
|
+
|
|
|
+ self.horizon.queue_draw()
|
|
|
+
|
|
|
+ # Statusy
|
|
|
+ if self.gamepad_connected:
|
|
|
+ self.gamepadStatusLabel.set_text("Gamepad: Ok")
|
|
|
+ self.gamepadStatusLabel.set_css_classes(['status-ok'])
|
|
|
+ else:
|
|
|
+ self.gamepadStatusLabel.set_text("Gamepad: Not Found")
|
|
|
+ self.gamepadStatusLabel.set_css_classes(['status-not-ok'])
|
|
|
+ return False
|
|
|
+
|
|
|
+ # Słuchanie zdarzeń z kontrolera
|
|
|
+ def monitor_controller(self):
|
|
|
+ raw_rx = 0
|
|
|
+ raw_ry = 0
|
|
|
+ while True:
|
|
|
+ try:
|
|
|
+ events = get_gamepad()
|
|
|
+
|
|
|
+ if not self.gamepad_connected:
|
|
|
+ print("Pad podłączony")
|
|
|
+ self.gamepad_connected = True
|
|
|
+
|
|
|
+ for event in events:
|
|
|
+ if event.code == 'BTN_START': # Przycisk START
|
|
|
+ if self.prev == 1 and event.state == 1:
|
|
|
+ self.toggle = not self.toggle
|
|
|
+ self.prev = not self.prev
|
|
|
+
|
|
|
+ elif event.code == 'ABS_Y': # Lewy Joystick, oś Y
|
|
|
+ if abs(event.state) > 3276:
|
|
|
+ self.left_y = round((event.state / 32768.0) * self.ZAKRES, 2)
|
|
|
+ else:
|
|
|
+ self.left_y = 0
|
|
|
+
|
|
|
+ elif event.code == 'ABS_RX': # Prawy Joystick, oś X
|
|
|
+ raw_rx = event.state
|
|
|
+
|
|
|
+ elif event.code == 'ABS_RY': # Prawy Joystick, oś Y
|
|
|
+ raw_ry = event.state
|
|
|
+
|
|
|
+ elif event.code == 'ABS_Z': # Klapa tylna lewa
|
|
|
+ self.flaps_zl = event.state
|
|
|
+
|
|
|
+ elif event.code == 'ABS_RZ': # Klapa tylna prawa
|
|
|
+ self.flaps_zr = event.state
|
|
|
+
|
|
|
+ # Wyprowadzenie wejścia z prawego joysticka z wykluczeniem kołowej martwej strefy
|
|
|
+ magnitude = math.sqrt(raw_rx**2 + raw_ry**2)
|
|
|
+ if magnitude > 3276:
|
|
|
+ self.right_x = round((raw_rx / 32768.0) * self.ZAKRES, 2)
|
|
|
+ self.right_y = round((raw_ry / 32768.0) * self.ZAKRES, 2)
|
|
|
+ else:
|
|
|
+ self.right_x = 0
|
|
|
+ self.right_y = 0
|
|
|
+
|
|
|
+ # Przeliczenie różnicy klap i skalowanie
|
|
|
+ self.flaps_z = round(((self.flaps_zr - self.flaps_zl) / 255) * self.ZAKRES, 2)
|
|
|
+
|
|
|
+ GLib.idle_add(self.update_ui)
|
|
|
+ except Exception as e:
|
|
|
+ if self.gamepad_connected:
|
|
|
+ print(f"Błąd: {e}")
|
|
|
+ self.gamepad_connected = False
|
|
|
+ self.right_x = 0
|
|
|
+ self.right_y = 0
|
|
|
+ self.left_y = 0
|
|
|
+ self.flaps_z = 0
|
|
|
+ GLib.idle_add(self.update_ui)
|
|
|
+
|
|
|
+ time.sleep(1)
|
|
|
+
|
|
|
+# Inicjalizacja aplikacji
|
|
|
+def on_activate(app):
|
|
|
+ window = DroneApplication(application=app)
|
|
|
+ window.present()
|
|
|
+
|
|
|
+app = Gtk.Application(application_id="com.cx.copter.app")
|
|
|
+app.connect("activate", on_activate)
|
|
|
+app.run(None)
|