bump version
[ppt-control.git] / ppt_control / ppt_control.py
index 6b0f8181ef68edd6bb72188e894b2026440cf5ac..4218f5c9ebdc59839e300a67d05da18bd1ff1349 100755 (executable)
@@ -32,16 +32,19 @@ global STATE
 global STATE_DEFAULT\r
 global current_slideshow\r
 global interface_root\r
+global interface_thread\r
 global logger\r
 global refresh_daemon\r
 global status_label\r
 global http_label\r
 global ws_label\r
+global reset_ppt_button\r
 global http_server\r
+global icon\r
 scheduler = None\r
 current_slideshow = None\r
-CACHEDIR = r'''C:\Windows\Temp\ppt-cache'''\r
 interface_root = None\r
+interface_thread = None\r
 CONFIG_FILE = r'''..\ppt-control.ini'''\r
 LOGFILE = r'''..\ppt-control.log'''\r
 logger = None\r
@@ -51,12 +54,18 @@ http_label = None
 ws_label = None\r
 ws_daemon = None\r
 http_server = None\r
+reset_ppt_button = None\r
+icon = None\r
 \r
 \r
 class Handler(server.SimpleHTTPRequestHandler):\r
     def __init__(self, *args, **kwargs):\r
         super().__init__(*args, directory=os.path.dirname(os.path.realpath(__file__)) + r'''\static''')\r
         \r
+    def log_request(self, code='-', size='-'):\r
+        return\r
+\r
+        \r
     def translate_path(self, path):\r
         """Translate a /-separated PATH to the local filename syntax.\r
 \r
@@ -81,12 +90,12 @@ class Handler(server.SimpleHTTPRequestHandler):
             black = 0\r
             if current_slideshow:\r
                 try:\r
-                    path = CACHEDIR + "\\" + current_slideshow.name()\r
+                    path = config.prefs["Main"]["cache"] + "\\" + current_slideshow.name()\r
                 except Exception as e:\r
-                    path = os.path.join(os.path.dirname(os.path.realpath(__file__)), r'''\static\black.jpg''') + '/'\r
+                    path = "black.jpg"\r
                     logger.warning("Failed to get current slideshow name: ", e)\r
             else:\r
-                path = os.path.join(os.path.dirname(os.path.realpath(__file__)), r'''\static\black.jpg''') + '/'\r
+                path = "black.jpg"\r
                 return path\r
             words.pop(0)\r
         else:\r
@@ -103,7 +112,7 @@ class Handler(server.SimpleHTTPRequestHandler):
 \r
 def run_http():\r
     global http_server\r
-    http_server = server.HTTPServer(("", 80), Handler)\r
+    http_server = server.HTTPServer((config.prefs["HTTP"]["interface"], config.prefs.getint("HTTP", "port")), Handler)\r
     http_server.serve_forever()\r
 \r
 STATE_DEFAULT = {"connected": 0, "current": 0, "total": 0, "visible": 0, "name": ""}\r
@@ -115,11 +124,7 @@ def state_event():
     return json.dumps({"type": "state", **STATE})\r
 \r
 \r
-def users_event():\r
-    return json.dumps({"type": "users", "count": len(USERS)})\r
-\r
-\r
-async def notify_state():\r
+def notify_state():\r
     global STATE\r
     if current_slideshow and STATE["connected"] == 1:\r
         try:\r
@@ -134,85 +139,85 @@ async def notify_state():
         STATE = copy(STATE_DEFAULT)\r
     if USERS:  # asyncio.wait doesn't accept an empty list\r
         message = state_event()\r
-        await asyncio.wait([user.send(message) for user in USERS])\r
+        loop.call_soon_threadsafe(ws_queue.put_nowait, state_event())\r
 \r
 \r
-async def notify_users():\r
-    if USERS:  # asyncio.wait doesn't accept an empty list\r
-        message = users_event()\r
-        await asyncio.wait([user.send(message) for user in USERS])\r
 \r
+async def ws_handler(websocket, path):\r
+    logger.debug("Handling WebSocket connection")\r
+    recv_task = asyncio.ensure_future(ws_receive(websocket, path))\r
+    send_task = asyncio.ensure_future(ws_send(websocket, path))\r
+    done, pending = await asyncio.wait(\r
+        [recv_task, send_task],\r
+        return_when=asyncio.FIRST_COMPLETED,\r
+    )\r
+    for task in pending:\r
+        task.cancel()\r
 \r
-async def register(websocket):\r
-    USERS.add(websocket)\r
-    await notify_users()\r
-\r
-\r
-async def unregister(websocket):\r
-    USERS.remove(websocket)\r
-    await notify_users()\r
-\r
-\r
-async def ws_handle(websocket, path):\r
+async def ws_receive(websocket, path):\r
     logger.debug("Received websocket request")\r
-    global current_slideshow\r
-    # register(websocket) sends user_event() to websocket\r
-    await register(websocket)\r
+    USERS.add(websocket)\r
     try:\r
-        await websocket.send(state_event())\r
+        # Send initial state to clients on load\r
+        notify_state()\r
         async for message in websocket:\r
+            logger.debug("Received websocket message: " + str(message))\r
             data = json.loads(message)\r
             if data["action"] == "prev":\r
                 if current_slideshow:\r
                     current_slideshow.prev()\r
-                await notify_state()\r
+                notify_state()\r
             elif data["action"] == "next":\r
                 if current_slideshow:\r
                     current_slideshow.next()\r
-                await notify_state()\r
+                notify_state()\r
             elif data["action"] == "first":\r
                 if current_slideshow:\r
                     current_slideshow.first()\r
-                await notify_state()\r
+                notify_state()\r
             elif data["action"] == "last":\r
                 if current_slideshow:\r
                     current_slideshow.last()\r
-                await notify_state()\r
+                notify_state()\r
             elif data["action"] == "black":\r
                 if current_slideshow:\r
                     if current_slideshow.visible() == 3:\r
                         current_slideshow.normal()\r
                     else:\r
                         current_slideshow.black()\r
-                await notify_state()\r
+                notify_state()\r
             elif data["action"] == "white":\r
                 if current_slideshow:\r
                     if current_slideshow.visible() == 4:\r
                         current_slideshow.normal()\r
                     else:\r
                         current_slideshow.white()\r
-                await notify_state()\r
+                notify_state()\r
             elif data["action"] == "goto":\r
                 if current_slideshow:\r
                     current_slideshow.goto(int(data["value"]))\r
-                await notify_state()\r
-            elif data["action"] == "refresh":\r
-                await notify_state()\r
-                if current_slideshow:\r
-                    current_slideshow.export_current_next()\r
-                    current_slideshow.refresh()\r
+                notify_state()\r
             else:\r
-                logger.error("unsupported event: {}", data)\r
+                logger.error("Received unnsupported event: {}", data)\r
     finally:\r
-        await unregister(websocket)\r
+        USERS.remove(websocket)\r
+\r
+async def ws_send(websocket, path):\r
+    while True:\r
+        message = await ws_queue.get()\r
+        await asyncio.wait([user.send(message) for user in USERS])\r
+\r
 \r
 def run_ws():\r
     # https://stackoverflow.com/questions/21141217/how-to-launch-win32-applications-in-separate-threads-in-python/22619084#22619084\r
     # https://www.reddit.com/r/learnpython/comments/mwt4qi/pywintypescom_error_2147417842_the_application/\r
     pythoncom.CoInitializeEx(pythoncom.COINIT_MULTITHREADED)\r
     asyncio.set_event_loop(asyncio.new_event_loop())\r
-    #start_server = websockets.serve(ws_handle, "0.0.0.0", 5678, ping_interval=None)\r
-    start_server = websockets.serve(ws_handle, "0.0.0.0", 5678)\r
+    global ws_queue\r
+    ws_queue = asyncio.Queue()\r
+    global loop\r
+    loop = asyncio.get_event_loop()\r
+    start_server = websockets.serve(ws_handler, config.prefs["WebSocket"]["interface"], config.prefs.getint("WebSocket", "port"), ping_interval=None)\r
     asyncio.get_event_loop().run_until_complete(start_server)\r
     asyncio.get_event_loop().run_forever()\r
 \r
@@ -229,6 +234,7 @@ def restart_http():
         http_server = None\r
         refresh_status()\r
     start_http()\r
+    time.sleep(0.5)\r
     refresh_status()\r
 \r
 def start_ws():\r
@@ -239,7 +245,7 @@ def start_ws():
     logger.info("Started websocket server")\r
 \r
 class Slideshow:\r
-    def __init__(self, instance):\r
+    def __init__(self, instance, blackwhite):\r
         self.instance = instance\r
         if self.instance is None:\r
             raise ValueError("PPT instance cannot be None")\r
@@ -252,13 +258,14 @@ class Slideshow:
             raise ValueError("PPT instance has no  active presentation")\r
         self.presentation = self.instance.ActivePresentation\r
 \r
+        self.blackwhite = blackwhite\r
+\r
         self.export_current_next()\r
 \r
     def unload(self):\r
         connect_ppt()\r
 \r
     def refresh(self):\r
-        logger.debug("Refreshing")\r
         try:\r
             if self.instance is None:\r
                 raise ValueError("PPT instance cannot be None")\r
@@ -340,7 +347,10 @@ class Slideshow:
     def black(self):\r
         try:\r
             self.refresh()\r
-            self.view.State = 3\r
+            if self.blackwhite == "both" and self.view.State == 4:\r
+                self.view.state = 1\r
+            else:\r
+                self.view.State = 3\r
             self.export_current_next()\r
         except (ValueError, pywintypes.com_error):\r
             self.unload()\r
@@ -348,7 +358,10 @@ class Slideshow:
     def white(self):\r
         try:\r
             self.refresh()\r
-            self.view.State = 4\r
+            if self.blackwhite == "both" and self.view.State == 3:\r
+                self.view.state = 1\r
+            else:\r
+                self.view.State = 4\r
             self.export_current_next()\r
         except (ValueError, pywintypes.com_error):\r
             self.unload()\r
@@ -375,20 +388,23 @@ class Slideshow:
         self.export(self.current_slide() + 2)\r
 \r
     def export(self, slide):\r
-        destination = CACHEDIR + "\\" + self.name() + "\\" + str(slide) + ".jpg"\r
+        destination = config.prefs["Main"]["cache"] + "\\" + self.name() + "\\" + str(slide) + ".jpg"\r
         os.makedirs(os.path.dirname(destination), exist_ok=True)\r
         if not os.path.exists(destination) or time.time() - os.path.getmtime(destination) > config.prefs.getint("Main", "cache_timeout"):\r
             if slide <= self.total_slides():\r
                 attempts = 0\r
                 while attempts < 3:\r
                     try:\r
-                        self.presentation.Slides(slide).Export(destination, "JPG")\r
+                        self.presentation.Slides(slide).Export(destination, config.prefs["Main"]["cache_format"])\r
                         break\r
                     except:\r
                         pass\r
                     attempts += 1\r
             elif slide == self.total_slides() + 1:\r
-                shutil.copyfileobj(open(os.path.dirname(os.path.realpath(__file__)), r'''\static\black.jpg''', 'rb'), open(destination, 'wb'))\r
+                try:\r
+                    shutil.copyfileobj(open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\black.jpg''', 'rb'), open(destination, 'wb'))\r
+                except Exception as e:\r
+                    logger.warning("Failed to copy black slide: " + str(e))\r
             else:\r
                 pass\r
 \r
@@ -407,34 +423,47 @@ def get_current_slideshow():
 \r
 def refresh_interval():\r
     while getattr(refresh_daemon, "do_run", True):\r
-        logger.debug("Triggering server-side refresh")\r
         current_slideshow.refresh()\r
+        notify_state()\r
         refresh_status()\r
-        time.sleep(1)\r
+        time.sleep(0.5)\r
 \r
 def refresh_status():\r
-    if status_label is not None:\r
-        status_label.config(text="PowerPoint status: " + ("not " if not STATE["connected"] else "") +  "connected")\r
-        http_label.config(text="HTTP server: " + ("not " if http_server is None else "") +  "running")\r
-        #ws_label.config(text="WebSocket server: " + ("not " if ws_daemon is not None or not ws_daemon.is_alive() else "") +  "running")\r
+    if interface_root is not None and interface_root.state == "normal":\r
+        logger.debug("Refreshing UI")\r
+        if status_label is not None:\r
+            status_label.config(text="PowerPoint status: " + ("not " if not STATE["connected"] else "") +  "connected")\r
+        if http_label is not None:\r
+            http_label.config(text="HTTP server: " + ("not " if http_server is None else "") +  "running")\r
+            #ws_label.config(text="WebSocket server: " + ("not " if ws_daemon is not None or not ws_daemon.is_alive() else "") +  "running")\r
+        if reset_ppt_button is not None:\r
+            reset_ppt_button.config(state = tk.DISABLED if not STATE["connected"] else tk.NORMAL)\r
 \r
 def connect_ppt():\r
     global STATE\r
     global refresh_daemon\r
     if STATE["connected"] == 1:\r
         logger.info("Disconnected from PowerPoint instance")\r
+        icon.notify("Disconnected from PowerPoint instance")\r
+        if reset_ppt_button is not None:\r
+            reset_ppt_button.config(state = tk.DISABLED)\r
         refresh_daemon.do_run = False\r
         STATE = copy(STATE_DEFAULT)\r
+        if icon is not None:\r
+            refresh_menu()\r
         refresh_status()\r
         logger.debug("State is now " + str(STATE))\r
     while True:\r
         try:\r
             instance = get_ppt_instance()\r
             global current_slideshow\r
-            current_slideshow = Slideshow(instance)\r
+            current_slideshow = Slideshow(instance, config.prefs["Main"]["blackwhite"])\r
             STATE["connected"] = 1\r
             STATE["current"] = current_slideshow.current_slide()\r
             STATE["total"] = current_slideshow.total_slides()\r
+            icon.notify("Connected to PowerPoint instance")\r
+            if icon is not None:\r
+                refresh_menu()\r
             refresh_status()\r
             logger.info("Connected to PowerPoint instance")\r
             refresh_daemon = threading.Thread(name="refresh_daemon", target=refresh_interval)\r
@@ -455,16 +484,22 @@ def on_closing():
     global status_label\r
     global http_label\r
     global ws_label\r
+    global interface_thread\r
     status_label = None\r
     http_label = None\r
     ws_label = None\r
+    logger.debug("Destroying interface root")\r
     interface_root.destroy()\r
+    logger.debug("Destroying interface thread")\r
+    interface_thread.root.quit()\r
+    interface_thread = None\r
     \r
 def open_settings(_=None):\r
     global interface_root\r
+    global interface_thread\r
     interface_root = tk.Tk()\r
     interface_root.protocol("WM_DELETE_WINDOW", on_closing)\r
-    interface_root.iconphoto(False, tk.PhotoImage(file="static/icons/ppt.png"))\r
+    interface_root.iconphoto(False, tk.PhotoImage(file=os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.png'''))\r
     interface_root.geometry("600x300+300+300")\r
     app = Interface(interface_root)\r
     interface_thread = threading.Thread(target=interface_root.mainloop())\r
@@ -490,6 +525,7 @@ class Interface(ttk.Frame):
         global status_label\r
         global http_label\r
         global ws_label\r
+        global reset_ppt_button\r
         self.parent.title("ppt-control")\r
         self.style = ttk.Style()\r
         #self.style.theme_use("default")\r
@@ -504,13 +540,14 @@ class Interface(ttk.Frame):
         save_button.place(x=400, y=280)\r
 \r
         reset_ppt_button = ttk.Button(self, text="Reconnect", command=connect_ppt)\r
+        reset_ppt_button.config(state = tk.DISABLED)\r
         reset_ppt_button.place(x=300, y=10)\r
 \r
         reset_http_button = ttk.Button(self, text="Restart", command=restart_http)\r
         reset_http_button.place(x=300, y=30)\r
 \r
-        reset_ws_button = ttk.Button(self, text="Restart", command=null_action)\r
-        reset_ws_button.place(x=300, y=50)\r
+        #reset_ws_button = ttk.Button(self, text="Restart", command=null_action)\r
+        #reset_ws_button.place(x=300, y=50)\r
 \r
         status_label = ttk.Label(self)\r
         status_label.place(x=10,y=10)\r
@@ -524,13 +561,24 @@ class Interface(ttk.Frame):
         refresh_status()\r
         \r
         \r
+def exit_action(icon):\r
+    logger.debug("User requested shutdown")\r
+    icon.visible = False\r
+    icon.stop()\r
+\r
+def refresh_menu():\r
+    icon.menu = (pystray.MenuItem("Status: " + "dis"*(not STATE["connected"]) + "connected", lambda: null_action(), enabled=False),\r
+            pystray.MenuItem("Stop", lambda: exit_action(icon)),\r
+            pystray.MenuItem("Settings", lambda: open_settings(), enabled=False)\r
+            )\r
 \r
 def show_icon():\r
+    global icon\r
     logger.debug("Starting system tray icon")\r
-    menu = (pystray.MenuItem("Status", lambda: null_action(), enabled=False),\r
-            pystray.MenuItem("Restart", lambda: start()),\r
-            pystray.MenuItem("Settings", lambda: open_settings()))\r
-    icon = pystray.Icon("ppt-control", Image.open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.ico'''), "ppt-control", menu)\r
+    icon = pystray.Icon("ppt-control")\r
+    icon.icon = Image.open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.ico''')\r
+    icon.title = "ppt-control"\r
+    refresh_menu()\r
     icon.visible = True\r
     icon.run(setup=start)\r
 \r
@@ -564,16 +612,17 @@ def start_interface():
     console_handler.setLevel(log_level)\r
     logger.addHandler(console_handler)\r
 \r
-    logging.getLogger("asyncio").setLevel(logging.ERROR)\r
-    logging.getLogger("asyncio.coroutines").setLevel(logging.ERROR)\r
+    #logging.getLogger("asyncio").setLevel(logging.ERROR)\r
+    #logging.getLogger("asyncio.coroutines").setLevel(logging.ERROR)\r
     logging.getLogger("websockets.server").setLevel(logging.ERROR)\r
-    logging.getLogger("websockets.protocol").setLevel(logging.ERROR)\r
+    #logging.getLogger("websockets.protocol").setLevel(logging.ERROR)\r
 \r
 \r
     logger.debug("Finished setting up config and logging")\r
 \r
     # Start systray icon and server\r
     show_icon()\r
+    sys.exit(0)\r
 \r
 if __name__ == "__main__":\r
     start_interface()\r