import pywintypes\r
import os\r
import shutil\r
-#import http.server as server\r
import socketserver\r
import threading\r
import asyncio\r
import time\r
import pythoncom\r
import pystray\r
-import tkinter as tk\r
-from tkinter import ttk\r
from PIL import Image\r
+from pystray._util import win32\r
from copy import copy\r
\r
-import ppt_control.http_server_39 as server\r
+import ppt_control.__init__ as pkg_base\r
+import ppt_control.http_server_39 as http_server # 3.9 version of the HTTP server (details in module)\r
import ppt_control.config as config\r
\r
logging.basicConfig()\r
\r
-global http_daemon\r
-global ws_daemon\r
-global STATE\r
-global STATE_DEFAULT\r
-global current_slideshow\r
-global interface_root\r
-global logger\r
-global refresh_daemon\r
-global status_label\r
-global http_label\r
-global ws_label\r
-global http_server\r
-scheduler = None\r
-current_slideshow = None\r
-CACHEDIR = r'''C:\Windows\Temp\ppt-cache'''\r
-interface_root = None\r
-CONFIG_FILE = r'''..\ppt-control.ini'''\r
-LOGFILE = r'''..\ppt-control.log'''\r
+http_daemon = None\r
+my_http_server = None\r
+ws_daemon = None\r
+users = set()\r
logger = None\r
refresh_daemon = None\r
-status_label = None\r
-http_label = None\r
-ws_label = None\r
-ws_daemon = None\r
-http_server = None\r
-\r
-\r
-class Handler(server.SimpleHTTPRequestHandler):\r
+icon = None\r
+ppt_application = None\r
+ppt_presentations = {}\r
+disable_protected_attempted = set()\r
+\r
+PKG_NAME = pkg_base.__name__\r
+PKG_VERSION = pkg_base.__version__\r
+DELAY_CLOSE = 0.2\r
+DELAY_PROTECTED = 0.5\r
+DELAY_FINAL = 0.1\r
+\r
+class MyIcon(pystray.Icon):\r
+ """\r
+ Custom pystray.Icon class which displays menu when left-clicking on icon, as well as the \r
+ default right-click behaviour.\r
+ """\r
+ def _on_notify(self, wparam, lparam):\r
+ """Handles ``WM_NOTIFY``.\r
+ If this is a left button click, this icon will be activated. If a menu\r
+ is registered and this is a right button click, the popup menu will be\r
+ displayed.\r
+ """\r
+ if lparam == win32.WM_LBUTTONUP or (\r
+ self._menu_handle and lparam == win32.WM_RBUTTONUP):\r
+ super()._on_notify(wparam, win32.WM_RBUTTONUP)\r
+\r
+class Handler(http_server.SimpleHTTPRequestHandler):\r
+ """\r
+ Custom handler to translate /cache* urls to the cache directory (set in the config)\r
+ """\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
words = path.split('/')\r
words = list(filter(None, words))\r
if len(words) > 0 and words[0] == "cache":\r
- black = 0\r
- if current_slideshow:\r
- try:\r
- path = CACHEDIR + "\\" + 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
- logger.warning("Failed to get current slideshow name: ", e)\r
+ if words[1] in ppt_presentations:\r
+ path = config.prefs["Main"]["cache"]\r
else:\r
- path = os.path.join(os.path.dirname(os.path.realpath(__file__)), r'''\static\black.jpg''') + '/'\r
- return path\r
+ path = "black.jpg"\r
+ logger.warning("Request for cached file {} for non-existent presentation".format(path))\r
words.pop(0)\r
else:\r
path = self.directory\r
path += '/'\r
return path\r
\r
-\r
-def run_http():\r
- global http_server\r
- http_server = server.HTTPServer(("", 80), Handler)\r
- http_server.serve_forever()\r
-\r
-STATE_DEFAULT = {"connected": 0, "current": 0, "total": 0, "visible": 0, "name": ""}\r
-STATE = copy(STATE_DEFAULT)\r
-USERS = set()\r
-\r
-\r
-def state_event():\r
- return json.dumps({"type": "state", **STATE})\r
-\r
-\r
-def notify_state():\r
- global STATE\r
- if current_slideshow and STATE["connected"] == 1:\r
- try:\r
- STATE["current"] = current_slideshow.current_slide()\r
- STATE["total"] = current_slideshow.total_slides()\r
- STATE["visible"] = current_slideshow.visible()\r
- STATE["name"] = current_slideshow.name()\r
- except Exception as e:\r
- logger.info("Failed to update state variables, presumably PPT instance doesn't exist anymore: {}".format(e))\r
- current_slideshow.unload()\r
- else:\r
- STATE = copy(STATE_DEFAULT)\r
- if USERS: # asyncio.wait doesn't accept an empty list\r
- message = state_event()\r
- loop.call_soon_threadsafe(ws_queue.put_nowait, state_event())\r
-\r
-\r
-\r
async def ws_handler(websocket, path):\r
+ """\r
+ Handle a WebSockets connection\r
+ """\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
task.cancel()\r
\r
async def ws_receive(websocket, path):\r
- logger.debug("Received websocket request")\r
- USERS.add(websocket)\r
+ """\r
+ Process data received on the WebSockets connection\r
+ """\r
+ users.add(websocket)\r
try:\r
# Send initial state to clients on load\r
- notify_state()\r
+ for pres in ppt_presentations:\r
+ broadcast_presentation(ppt_presentations[pres])\r
async for message in websocket:\r
logger.debug("Received websocket message: " + str(message))\r
data = json.loads(message)\r
+ if data["presentation"]:\r
+ pres = ppt_presentations[data["presentation"]]\r
+ else:\r
+ # Control last-initialised presentation if none specified (e.g. if using OBS script\r
+ # which doesn't have any visual feedback and hence no method to choose a\r
+ # presentation). This relies on any operations on the ppt_presentations dictionary\r
+ # being stable so that the order does not change. So far no problems have been\r
+ # detected with this, but it is not an ideal method.\r
+ pres = ppt_presentations[list(ppt_presentations.keys())[-1]]\r
if data["action"] == "prev":\r
- if current_slideshow:\r
- current_slideshow.prev()\r
- notify_state()\r
+ pres.prev()\r
elif data["action"] == "next":\r
- if current_slideshow:\r
- current_slideshow.next()\r
- notify_state()\r
+ pres.next()\r
+ # Advancing to the black screen before the slideshow ends doesn't trigger \r
+ # ApplicationEvents.OnSlideShowNextSlide, so we have to check for that here and\r
+ # broadcast the new state if necessary. A delay is required since the event is \r
+ # triggered before the slideshow is actually closed, and we don't want to attempt\r
+ # to check the current slide of a slideshow that isn't running.\r
+ time.sleep(DELAY_FINAL)\r
+ if (pres.get_slideshow() is not None and \r
+ pres.slide_current() == pres.slide_total() + 1):\r
+ logger.debug("Advanced to black slide before end")\r
+ broadcast_presentation(pres)\r
elif data["action"] == "first":\r
- if current_slideshow:\r
- current_slideshow.first()\r
- notify_state()\r
+ pres.first()\r
elif data["action"] == "last":\r
- if current_slideshow:\r
- current_slideshow.last()\r
- notify_state()\r
+ pres.last()\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
- notify_state()\r
+ if pres.state() == 3 or (\r
+ config.prefs["Main"]["blackwhite"] == "both" and pres.state() == 4):\r
+ pres.normal()\r
+ else:\r
+ pres.black()\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
- notify_state()\r
+ if pres.state() == 4 or (\r
+ config.prefs["Main"]["blackwhite"] == "both" and pres.state() == 3):\r
+ pres.normal()\r
+ else:\r
+ pres.white()\r
elif data["action"] == "goto":\r
- if current_slideshow:\r
- current_slideshow.goto(int(data["value"]))\r
- notify_state()\r
+ pres.goto(int(data["value"]))\r
+ # Advancing to the black screen before the slideshow ends doesn't trigger \r
+ # ApplicationEvents.OnSlideShowNextSlide, so we have to check for that here and\r
+ # broadcast the new state if necessary. A delay is required since the event is \r
+ # triggered before the slideshow is actually closed, and we don't want to attempt\r
+ # to check the current slide of a slideshow that isn't running.\r
+ time.sleep(DELAY_FINAL)\r
+ if (pres.get_slideshow() is not None and \r
+ pres.slide_current() == pres.slide_total() + 1):\r
+ logger.debug("Jumped to black slide before end")\r
+ broadcast_presentation(pres)\r
+ elif data["action"] == "start":\r
+ pres.start_slideshow()\r
+ elif data["action"] == "stop":\r
+ pres.stop_slideshow()\r
else:\r
logger.error("Received unnsupported event: {}", data)\r
- message = ""\r
finally:\r
- USERS.remove(websocket)\r
+ users.remove(websocket)\r
\r
async def ws_send(websocket, path):\r
+ """\r
+ Broadcast data to all WebSockets clients\r
+ """\r
while True:\r
message = await ws_queue.get()\r
- await asyncio.wait([user.send(message) for user in USERS])\r
+ await asyncio.wait([user.send(message) for user in users])\r
+\r
+\r
+def run_http():\r
+ """\r
+ Start the HTTP server\r
+ """\r
+ global my_http_server\r
+ my_http_server = http_server.HTTPServer((config.prefs["HTTP"]["interface"], config.prefs.getint("HTTP", "port")), Handler)\r
+ my_http_server.serve_forever()\r
\r
\r
def run_ws():\r
+ """\r
+ Set up threading/async for WebSockets server\r
+ """\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
ws_queue = asyncio.Queue()\r
global loop\r
loop = asyncio.get_event_loop()\r
- start_server = websockets.serve(ws_handler, "0.0.0.0", 5678, ping_interval=None)\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
-def start_http():\r
+def setup_http():\r
+ """\r
+ Set up threading for HTTP server\r
+ """\r
http_daemon = threading.Thread(name="http_daemon", target=run_http)\r
http_daemon.setDaemon(True)\r
http_daemon.start()\r
logger.info("Started HTTP server")\r
\r
-def restart_http():\r
- global http_server\r
- if http_server:\r
- http_server.shutdown()\r
- http_server = None\r
- refresh_status()\r
- start_http()\r
- refresh_status()\r
-\r
-def start_ws():\r
+def setup_ws():\r
+ """\r
+ Set up threading for WebSockets server\r
+ """\r
global ws_daemon\r
ws_daemon = threading.Thread(name="ws_daemon", target=run_ws)\r
ws_daemon.setDaemon(True)\r
ws_daemon.start()\r
logger.info("Started websocket server")\r
+ \r
\r
-class Slideshow:\r
- def __init__(self, instance):\r
- self.instance = instance\r
- if self.instance is None:\r
- raise ValueError("PPT instance cannot be None")\r
+def broadcast_presentation(pres):\r
+ """\r
+ Broadcast the state of a single presentation to all connected clients. Also ensures the current\r
+ slide and the two upcoming slides are exported and cached.\r
+ """\r
+ name = pres.presentation.Name\r
+ pres_open = name in ppt_presentations\r
+ slideshow = pres.slideshow is not None\r
+ visible = pres.state()\r
+ slide_current = pres.slide_current()\r
+ slide_total = pres.slide_total()\r
+\r
+ pres.export_current_next()\r
+ \r
+ if users: # asyncio.wait doesn't accept an empty list\r
+ state = {"name": name, "pres_open": pres_open, "slideshow": slideshow, "visible": visible,\r
+ "slide_current": slide_current, "slide_total": slide_total}\r
+ loop.call_soon_threadsafe(ws_queue.put_nowait, json.dumps({"type": "state", **state}))\r
+\r
+class ApplicationEvents:\r
+ """\r
+ Events assigned to the root application.\r
+ Ref: https://docs.microsoft.com/en-us/office/vba/api/powerpoint.application#events\r
+ """\r
+ def OnSlideShowNextSlide(self, window, *args):\r
+ """\r
+ Triggered when the current slide number of any slideshow is incremented, locally or through\r
+ ppt_control.\r
+ """\r
+ logger.debug("Slide advanced for {}".format(window.Presentation.Name))\r
+ broadcast_presentation(ppt_presentations[window.Presentation.Name])\r
\r
- if self.instance.SlideShowWindows.Count == 0:\r
- raise ValueError("PPT instance has no slideshow windows")\r
- self.view = self.instance.SlideShowWindows[0].View\r
+ def OnSlideShowPrevSlide(self, window, *args):\r
+ """\r
+ Triggered when the current slide number of any slideshow is decremented, locally or through\r
+ ppt_control.\r
+ """\r
+ logger.debug("Slide decremented for {}".format(window.Presentation.Name))\r
+ broadcast_presentation(ppt_presentations[window.Presentation.Name])\r
+ \r
+ def OnAfterPresentationOpen(self, presentation, *args):\r
+ """\r
+ Triggered when an existing presentation is opened. This adds the newly opened presentation\r
+ to the list of open presentations.\r
+ """\r
+ logger.debug("Presentation {} opened - adding to list".format(presentation.Name))\r
+ global ppt_presentations\r
+ ppt_presentations[presentation.Name] = Presentation(ppt_application, pres_obj=presentation)\r
+ broadcast_presentation(ppt_presentations[presentation.Name])\r
+ disable_protected_attempted.discard(presentation.Name)\r
+ icon.notify("Connected to {}".format(presentation.Name), PKG_NAME)\r
+\r
+ def OnAfterNewPresentation(self, presentation, *args):\r
+ """\r
+ Triggered when a new presentation is opened. This adds the new presentation to the list\r
+ of open presentations.\r
+ """\r
+ logger.debug("Presentation {} opened (blank) - adding to list".format(presentation.Name))\r
+ global ppt_presentations\r
+ ppt_presentations[presentation.Name] = Presentation(ppt_application, pres_obj=presentation)\r
+ broadcast_presentation(ppt_presentations[presentation.Name])\r
+ icon.notify("Connected to {}".format(presentation.Name), PKG_NAME)\r
\r
- if self.instance.ActivePresentation is None:\r
- raise ValueError("PPT instance has no active presentation")\r
- self.presentation = self.instance.ActivePresentation\r
+ def OnPresentationClose(self, presentation, *args):\r
+ """\r
+ Triggered when a presentation is closed. This removes the presentation from the list of\r
+ open presentations. A delay is included to make sure the presentation is \r
+ actually closed, since the event is called simultaneously as the presentation is removed\r
+ from PowerPoint's internal structure. Ref:\r
+ https://docs.microsoft.com/en-us/office/vba/api/powerpoint.application.presentationclose\r
+ """\r
+ logger.debug("Presentation {} closed - removing from list".format(presentation.Name))\r
+ global ppt_presentations\r
+ time.sleep(DELAY_CLOSE)\r
+ broadcast_presentation(ppt_presentations.pop(presentation.Name))\r
+ icon.notify("Disconnected from {}".format(presentation.Name), PKG_NAME)\r
\r
- self.export_current_next()\r
+ def OnSlideShowBegin(self, window, *args):\r
+ """\r
+ Triggered when a slideshow is started. This initialises the Slideshow object in the \r
+ appropriate Presentation object.\r
+ """\r
+ logger.debug("Slideshow started for {}".format(window.Presentation.Name))\r
+ global ppt_presentations\r
+ ppt_presentations[window.Presentation.Name].slideshow = window\r
+ broadcast_presentation(ppt_presentations[window.Presentation.Name])\r
+ \r
+ def OnSlideShowEnd(self, presentation, *args):\r
+ """\r
+ Triggered when a slideshow is ended. This deinitialises the Slideshow object in the \r
+ appropriate Presentation object.\r
+ """\r
+ logger.debug("Slideshow ended for {}".format(presentation.Name))\r
+ global ppt_presentations\r
+ ppt_presentations[presentation.Name].slideshow = None\r
+ broadcast_presentation(ppt_presentations[presentation.Name])\r
+\r
+\r
+class Presentation:\r
+ """\r
+ Class representing an instance of PowerPoint with a file open (so-called "presentation"\r
+ in PowerPoint terms). Mostly just a wrapper for PowerPoint's `Presentation` object.\r
+ """\r
+ def __init__(self, application, pres_index=None, pres_obj=None):\r
+ """\r
+ Initialise a Presentation object.\r
+ application The PowerPoint application which the presentation is being run within\r
+ pres_index PowerPoint's internal presentation index (NOTE this is indexed from 1)\r
+ """\r
+ if pres_index == None and pres_obj == None:\r
+ raise ValueError("Cannot initialise a presentation without a presentation ID or object")\r
+ assert len(application.Presentations) > 0, "Cannot initialise presentation from application with no presentations"\r
+\r
+ self.__application = application\r
+ if pres_obj is not None:\r
+ self.presentation = pres_obj\r
+ else:\r
+ self.presentation = application.Presentations(pres_index)\r
+ self.slideshow = self.get_slideshow()\r
\r
- def unload(self):\r
- connect_ppt()\r
\r
- def refresh(self):\r
+ def get_slideshow(self):\r
+ """\r
+ Check whether the presentation is in slideshow mode, and if so, return the SlideShowWindow.\r
+ """\r
try:\r
- if self.instance is None:\r
- raise ValueError("PPT instance cannot be None")\r
+ return self.presentation.SlideShowWindow\r
+ except pywintypes.com_error as exc:\r
+ logger.debug("Couldn't get slideshow for {}: {}".format(self.presentation.Name, exc))\r
+ return None\r
\r
- if self.instance.SlideShowWindows.Count == 0:\r
- raise ValueError("PPT instance has no slideshow windows")\r
- self.view = self.instance.SlideShowWindows[0].View\r
+ def start_slideshow(self):\r
+ """\r
+ Start the slideshow. Updating the state of this object is managed by the OnSlideshowBegin \r
+ event of the applicable Application.\r
+ """\r
+ if self.get_slideshow() is None:\r
+ self.presentation.SlideShowSettings.Run()\r
+ else:\r
+ logger.warning("Cannot start slideshow that is already running (presentation {})".format(\r
+ self.presentation.Name))\r
\r
- if self.instance.ActivePresentation is None:\r
- raise ValueError("PPT instance has no active presentation")\r
- except:\r
- self.unload()\r
+ def stop_slideshow(self):\r
+ """\r
+ Stop the slideshow. Updating the state of this object is managed by the OnSlideshowEnd\r
+ event of the applicable Application.\r
+ """\r
+ if self.get_slideshow() is not None:\r
+ self.presentation.SlideShowWindow.View.Exit()\r
+ else:\r
+ logger.warning("Cannot stop slideshow that is not running (presentation {})".format(\r
+ self.presentation.Name))\r
\r
- def total_slides(self):\r
- try:\r
- self.refresh()\r
- return len(self.presentation.Slides)\r
- except (ValueError, pywintypes.com_error):\r
- self.unload()\r
+ def state(self):\r
+ """\r
+ Returns the visibility state of the slideshow:\r
+ 1: running\r
+ 2: paused\r
+ 3: black\r
+ 4: white\r
+ 5: done\r
+ Source: https://docs.microsoft.com/en-us/office/vba/api/powerpoint.ppslideshowstate\r
+ """\r
+ if self.slideshow is not None:\r
+ return self.slideshow.View.State\r
+ else:\r
+ return 0\r
\r
- def current_slide(self):\r
- try:\r
- self.refresh()\r
- return self.view.CurrentShowPosition\r
- except (ValueError, pywintypes.com_error):\r
- self.unload()\r
+ def slide_current(self):\r
+ """\r
+ Returns the current slide number of the slideshow, or 0 if no slideshow is running.\r
+ """\r
+ if self.slideshow is not None:\r
+ return self.slideshow.View.CurrentShowPosition\r
+ else:\r
+ return 0\r
\r
- def visible(self):\r
- try:\r
- self.refresh()\r
- return self.view.State\r
- except (ValueError, pywintypes.com_error):\r
- self.unload()\r
+ def slide_total(self):\r
+ """\r
+ Returns the total number of slides in the presentation, regardless of whether a slideshow\r
+ is running.\r
+ """\r
+ return self.presentation.Slides.Count\r
\r
def prev(self):\r
- try:\r
- self.refresh()\r
- self.view.Previous()\r
- self.export_current_next()\r
- except (ValueError, pywintypes.com_error):\r
- self.unload()\r
+ """\r
+ Go to the previous slide if there is a slideshow running. Notifying clients of the new state\r
+ is managed by the ApplicationEvent.\r
+ """\r
+ assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+ self.slideshow.View.Previous()\r
\r
def next(self):\r
- try:\r
- self.refresh()\r
- self.view.Next()\r
- self.export_current_next()\r
- except (ValueError, pywintypes.com_error):\r
- self.unload()\r
+ """\r
+ Go to the previous slide if there is a slideshow running. Notifying clients of the new state\r
+ is managed by the ApplicationEvent.\r
+ """\r
+ assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+ self.slideshow.View.Next()\r
\r
def first(self):\r
- try:\r
- self.refresh()\r
- self.view.First()\r
- self.export_current_next()\r
- except (ValueError, pywintypes.com_error):\r
- self.unload()\r
+ """\r
+ Go to the first slide if there is a slideshow running. Notifying clients of the new state\r
+ is managed by the ApplicationEvent.\r
+ """\r
+ assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+ self.slideshow.View.First()\r
\r
def last(self):\r
- try:\r
- self.refresh()\r
- self.view.Last()\r
- self.export_current_next()\r
- except (ValueError, pywintypes.com_error):\r
- self.unload()\r
+ """\r
+ Go to the last slide if there is a slideshow running. Notifying clients of the new state\r
+ is managed by the ApplicationEvent.\r
+ """\r
+ assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+ self.slideshow.View.Last()\r
\r
def goto(self, slide):\r
- try:\r
- self.refresh()\r
- if slide <= self.total_slides():\r
- self.view.GotoSlide(slide)\r
- else:\r
- self.last()\r
- self.next()\r
- self.export_current_next()\r
- except (ValueError, pywintypes.com_error):\r
- self.unload()\r
-\r
- def black(self):\r
- try:\r
- self.refresh()\r
- self.view.State = 3\r
- self.export_current_next()\r
- except (ValueError, pywintypes.com_error):\r
- self.unload()\r
-\r
- def white(self):\r
- try:\r
- self.refresh()\r
- self.view.State = 4\r
- self.export_current_next()\r
- except (ValueError, pywintypes.com_error):\r
- self.unload()\r
+ """\r
+ Go to a numbered slide if there is a slideshow running. Notifying clients of the new state\r
+ is managed by the ApplicationEvent.\r
+ """\r
+ assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+ if slide <= self.slide_total():\r
+ self.slideshow.View.GotoSlide(slide)\r
+ else:\r
+ self.last()\r
+ self.next()\r
\r
def normal(self):\r
- try:\r
- self.refresh()\r
- self.view.State = 1\r
- self.export_current_next()\r
- except (ValueError, pywintypes.com_error):\r
- self.unload()\r
+ """\r
+ Make the slideshow visible if there is a slideshow running. Note this puts the slideshow into \r
+ "running" state rather than the normal "paused" to ensure animations work correctly and the \r
+ slide is actually visible after changing the state. The state is normally returned to \r
+ "paused" automatically by PPT when advancing to the following slide. State enumeration ref: \r
+ https://docs.microsoft.com/en-us/office/vba/api/powerpoint.ppslideshowstate\r
+ """\r
+ assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+ self.slideshow.View.State = 1\r
+ broadcast_presentation(self)\r
\r
- def name(self):\r
- try:\r
- self.refresh()\r
- return self.presentation.Name\r
- except (ValueError, pywintypes.com_error):\r
- self.unload()\r
+ def black(self):\r
+ """\r
+ Make the slideshow black if there is a slideshow running. \r
+ """\r
+ assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+ self.slideshow.View.State = 3\r
+ broadcast_presentation(self)\r
\r
+ def white(self):\r
+ """\r
+ Make the slideshow white if there is a slideshow running. \r
+ """\r
+ assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+ self.slideshow.View.State = 4\r
+ broadcast_presentation(self)\r
\r
def export_current_next(self):\r
- self.export(self.current_slide())\r
- self.export(self.current_slide() + 1)\r
- self.export(self.current_slide() + 2)\r
+ """\r
+ Export the current slide, the next slide, and the one after (ensures enough images are \r
+ always cached)\r
+ """\r
+ self.export(self.slide_current())\r
+ self.export(self.slide_current() + 1)\r
+ self.export(self.slide_current() + 2)\r
\r
def export(self, slide):\r
- destination = CACHEDIR + "\\" + self.name() + "\\" + str(slide) + ".jpg"\r
+ """\r
+ Export a relatively low-resolution image of a slide using PowerPoint's built-in export \r
+ function. The cache destination is set in the config.\r
+ """\r
+ destination = config.prefs["Main"]["cache"] + "\\" + self.presentation.Name + "\\" + str(slide) + "." + config.prefs["Main"]["cache_format"].lower()\r
+ logger.debug("Exporting slide {} of {}".format(slide, self.presentation.Name))\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
+ if slide <= self.slide_total():\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
+ elif slide == self.slide_total() + 1:\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
+ shutil.copyfileobj(open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\black.jpg''', 'rb'), open(destination, 'wb'))\r
+ except Exception as exc:\r
+ logger.warning("Failed to copy black slide (number {} for presentation {}): {}".format(slide, self.presentation.Name, exc))\r
else:\r
pass\r
\r
def export_all(self):\r
- for i in range(1, self.total_slides()):\r
+ """\r
+ Export all slides in the presentation\r
+ """\r
+ for i in range(1, self.slide_total() + 2):\r
self.export(i)\r
\r
-def get_ppt_instance():\r
- instance = win32com.client.Dispatch('Powerpoint.Application')\r
- if instance is None or instance.SlideShowWindows.Count == 0:\r
- return None\r
- return instance\r
-\r
-def get_current_slideshow():\r
- return current_slideshow\r
+def null_action(*args):\r
+ """\r
+ Placeholder for disabled menu items in systray\r
+ """\r
+ pass\r
\r
-def refresh_interval():\r
+def edit_config(*args):\r
+ """\r
+ Open the config file for editing in Notepad, and create the directory if not existing\r
+ """\r
+ logger.debug("Opening config {}".format(pkg_base.CONFIG_PATH))\r
+ if not os.path.exists(pkg_base.CONFIG_DIR):\r
+ try:\r
+ os.makedirs(pkg_base.CONFIG_DIR)\r
+ logger.info("Made directory {}".format(pkg_base.CONFIG_DIR))\r
+ except Exception as exc:\r
+ logger.warning("Failed to create directory {} for config file".format(\r
+ pkg_base.CONFIG_DIR))\r
+ icon.notify("Create {} manually".format((pkg_base.CONFIG_DIR[:40] + '...') \r
+ if len(pkg_base.CONFIG_DIR) > 40 else pkg_base.CONFIG_DIR),\r
+ "Failed to create config directory")\r
+ try:\r
+ os.popen("notepad.exe {}".format(pkg_base.CONFIG_PATH))\r
+ except Exception as exc:\r
+ logger.warning("Failed to edit config {}: {}".format(pkg_base.CONFIG_PATH, exc))\r
+ icon.notify("Edit {} manually".format((pkg_base.CONFIG_PATH[:40] + '...') \r
+ if len(pkg_base.CONFIG_PATH) > 40 else pkg_base.CONFIG_PATH),\r
+ "Failed to open config")\r
+ \r
+def refresh():\r
+ """\r
+ Clear COM events and update interface elements at an interval defined in "refresh" in the config\r
+ TODO: fix "argument of type 'com_error' is not iterable"\r
+ """\r
while getattr(refresh_daemon, "do_run", True):\r
- current_slideshow.refresh()\r
- notify_state()\r
- refresh_status()\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
-\r
-def connect_ppt():\r
- global STATE\r
- global refresh_daemon\r
- if STATE["connected"] == 1:\r
- logger.info("Disconnected from PowerPoint instance")\r
- refresh_daemon.do_run = False\r
- STATE = copy(STATE_DEFAULT)\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
- STATE["connected"] = 1\r
- STATE["current"] = current_slideshow.current_slide()\r
- STATE["total"] = current_slideshow.total_slides()\r
- refresh_status()\r
- logger.info("Connected to PowerPoint instance")\r
- refresh_daemon = threading.Thread(name="refresh_daemon", target=refresh_interval)\r
- refresh_daemon.setDaemon(True)\r
- refresh_daemon.start()\r
- break\r
- except ValueError as e:\r
- current_slideshow = None\r
- pass\r
- time.sleep(1)\r
-\r
-def start(_=None):\r
- start_http()\r
- start_ws()\r
- connect_ppt()\r
-\r
-def on_closing():\r
- global status_label\r
- global http_label\r
- global ws_label\r
- status_label = None\r
- http_label = None\r
- ws_label = None\r
- interface_root.destroy()\r
- \r
-def open_settings(_=None):\r
- global interface_root\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.geometry("600x300+300+300")\r
- app = Interface(interface_root)\r
- interface_thread = threading.Thread(target=interface_root.mainloop())\r
- interface_thread.setDaemon(True)\r
- interface_thread.start()\r
-\r
-def null_action():\r
- pass\r
-\r
-def save_settings():\r
- pass\r
-\r
-class Interface(ttk.Frame):\r
-\r
- def __init__(self, parent):\r
- ttk.Frame.__init__(self, parent)\r
-\r
- self.parent = parent\r
-\r
- self.initUI()\r
-\r
- def initUI(self):\r
- global status_label\r
- global http_label\r
- global ws_label\r
- self.parent.title("ppt-control")\r
- self.style = ttk.Style()\r
- #self.style.theme_use("default")\r
- self.focus_force()\r
-\r
- self.pack(fill=tk.BOTH, expand=1)\r
-\r
- quitButton = ttk.Button(self, text="Cancel", command=interface_root.destroy)\r
- quitButton.place(x=480, y=280)\r
-\r
- save_button = ttk.Button(self, text="OK", command=save_settings)\r
- save_button.place(x=400, y=280)\r
-\r
- reset_ppt_button = ttk.Button(self, text="Reconnect", command=connect_ppt)\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
+ pythoncom.PumpWaitingMessages()\r
+ # TODO: don't regenerate entire menu on each refresh, use pystray.Icon.update_menu()\r
+ icon.menu = (pystray.MenuItem("Status: " + "dis"*(len(ppt_presentations) == 0) + "connected",\r
+ lambda: null_action(), enabled=False),\r
+ pystray.MenuItem("Stop", lambda: exit(icon)),\r
+ pystray.MenuItem("Edit config", lambda: edit_config()))\r
+ manage_protected_view(ppt_application)\r
+ time.sleep(float(config.prefs["Main"]["refresh"]))\r
+ except Exception as exc:\r
+ # Deal with any exceptions, such as RPC server restarting, by reconnecting to application\r
+ # (if this fails again, that's okay because we'll keep trying until it works)\r
+ logger.error("Error whilst refreshing state: {}".format(exc))\r
+ app = get_application()\r
+ \r
\r
- reset_ws_button = ttk.Button(self, text="Restart", command=null_action)\r
- reset_ws_button.place(x=300, y=50)\r
+def get_application():\r
+ """\r
+ Create an Application object representing the PowerPoint application installed on the machine.\r
+ This should succeed regardless of whether PowerPoint is running, as long as PowerPoint is\r
+ installed.\r
+ Returns the Application object if successful, otherwise returns None.\r
+ """\r
+ try:\r
+ return win32com.client.Dispatch('PowerPoint.Application')\r
+ except pywintypes.com_error:\r
+ # PowerPoint is probably not installed, or other COM failure\r
+ return None\r
+ \r
\r
- status_label = ttk.Label(self)\r
- status_label.place(x=10,y=10)\r
+def manage_protected_view(app):\r
+ """\r
+ Attempt to unlock any presentations that have been opened in protected view. These cannot be\r
+ controlled by the program whilst they are in protected view, so we attempt to disable protected\r
+ view, or show a notification if this doesn't work for some reason.\r
+ """\r
+ try:\r
+ if app.ProtectedViewWindows.Count > 0:\r
+ logger.debug("Found open presentation(s) but at least one is in protected view")\r
+ for i in range(1, app.ProtectedViewWindows.Count + 1): # +1 to account for indexing from 1\r
+ pres_name = app.ProtectedViewWindows(i).Presentation.Name\r
+ if pres_name in disable_protected_attempted:\r
+ continue\r
+ if config.prefs.getboolean("Main", "disable_protected"):\r
+ try:\r
+ app.ProtectedViewWindows(i).Edit()\r
+ logger.info("Enabled editing for {}".format(pres_name))\r
+ except Exception as exc:\r
+ icon.notify("Failed to disable protected view on \"{}\"".format((pres_name[:22] + '...') \r
+ if len(pres_name) > 25 else pres_name), "Disable protected view in PowerPoint")\r
+ logger.warning("Failed to disable protected view {} for editing - do this manually: {}".format(pres_name, exc))\r
+ disable_protected_attempted.add(pres_name)\r
+ else:\r
+ icon.notify("Cannot control \"{}\" in protected view".format((pres_name[:22] + '...') \r
+ if len(pres_name) > 25 else pres_name), "Disable protected view in PowerPoint")\r
+ logger.warning("Cannot control {} in protected view, and automatic disabling of protected view is turned off".format(pres_name))\r
+ disable_protected_attempted.add(pres_name)\r
+ except Exception as exc:\r
+ if type(exc) == pywintypes.com_error and "application is busy" in exc:\r
+ # PowerPoint needs some time to finish loading file if it has just been opened,\r
+ # otherwise we get "The message filter indicated that the application is busy". Here,\r
+ # we deal with this by gracefully ignoring any protected view windows until the next \r
+ # refresh cycle, when PowerPoint is hopefully finished loading (if the refresh interval\r
+ # is sufficiently long).\r
+ logger.debug("COM interface not taking requests right now - will try again on next refresh")\r
+ return\r
+ # Sometimes gets pywintypes.com_error "The object invoked has disconnected from its clients"\r
+ # at this point.\r
+ logger.warning("{} whilst dealing with protected view windows: {}".format(type(exc), exc))\r
+ app = get_application()\r
\r
- http_label = ttk.Label(self)\r
- http_label.place(x=10,y=30)\r
\r
- ws_label = ttk.Label(self)\r
- ws_label.place(x=10,y=50)\r
\r
- refresh_status()\r
+def connect_ppt():\r
+ """\r
+ Connect to the PowerPoint COM interface and perform initial enumeration of open files \r
+ ("presentations"). Files that are subsequently opened are dealt with using COM events (see the\r
+ ApplicationEvents class above). Therefore, once we are finished setting things up, we just \r
+ call refresh() as a daemon in order to keep clients up to date.\r
+ """\r
+ logger.debug("Searching for a PowerPoint slideshow...")\r
+ global ppt_application\r
+ global ppt_presentations\r
+\r
+ # Initialise PowerPoint application\r
+ ppt_application = get_application()\r
+ if ppt_application is None:\r
+ # Couldn't find PowerPoint application\r
+ icon.notify("Couldn't find PowerPoint application", "Error starting {}".format(PKG_NAME))\r
+ logger.error("Couldn't find PowerPoint application - check that PowerPoint is installed and COM is working")\r
+ sys.exit()\r
+\r
+ # Continue because we can connect to PowerPoint\r
+ logger.debug("Found PowerPoint application")\r
+\r
+ # Dispatch events\r
+ win32com.client.WithEvents(ppt_application, ApplicationEvents)\r
+ logger.debug("Dispatched events")\r
+\r
+ # Deal with windows in protected view\r
+ manage_protected_view(ppt_application)\r
+\r
+ # Initial enumeration of open presentations\r
+ logger.debug("Enumerating {} presentation(s)".format(len(ppt_application.Presentations)))\r
+ for n in range(1, len(ppt_application.Presentations)+1): # PowerPoint's slide indexing starts at 1.. why!?!?!?\r
+ pres = Presentation(ppt_application, n)\r
+ icon.notify("Connected to {}".format(pres.presentation.Name), PKG_NAME)\r
+ logger.debug("Found presentation {} with index {}".format(pres.presentation.Name, n))\r
+ ppt_presentations[pres.presentation.Name] = pres\r
+ refresh_daemon = threading.Thread(name="refresh_daemon", target=refresh)\r
+ refresh_daemon.setDaemon(True)\r
+ logger.debug("Handing over to refresh daemon - goodbye...")\r
+ if len(ppt_presentations) == 0:\r
+ # Provide some confirmation that the program has started if we haven't sent any \r
+ # connection notifications yet\r
+ icon.notify("Started server", PKG_NAME)\r
+ refresh_daemon.start()\r
+\r
+\r
+def start_server(_=None):\r
+ """\r
+ Start HTTP and WS servers, then connect to PPT instance with connect_ppt() which will then \r
+ set off the refresh daemon.\r
+ """\r
+ setup_http()\r
+ setup_ws()\r
+ connect_ppt()\r
\r
\r
+def exit(icon):\r
+ """\r
+ Clean up and exit when user clicks "Stop" from systray menu\r
+ """\r
+ logger.debug("User requested shutdown")\r
+ icon.visible = False\r
+ icon.stop()\r
\r
-def show_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.visible = True\r
- icon.run(setup=start)\r
\r
def start_interface():\r
+ """\r
+ Main entrypoint for the program. Loads config and logging, starts systray icon, and calls\r
+ start_server() to start the backend.\r
+ """\r
+ global icon\r
global logger\r
-\r
# Load config\r
- config.prefs = config.loadconf(CONFIG_FILE)\r
+ config.prefs = config.loadconf(pkg_base.CONFIG_PATH)\r
\r
# Set up logging\r
if config.prefs["Main"]["logging"] == "debug":\r
logger.setLevel(log_level)\r
logger.propagate = False\r
\r
- file_handler = logging.FileHandler("{0}/{1}".format(os.getenv("APPDATA"), LOGFILE))\r
- file_handler.setFormatter(log_formatter)\r
- file_handler.setLevel(log_level)\r
- logger.addHandler(file_handler)\r
-\r
console_handler = logging.StreamHandler()\r
console_handler.setFormatter(log_formatter)\r
console_handler.setLevel(log_level)\r
logger.addHandler(console_handler)\r
\r
+ if not os.path.exists(pkg_base.LOG_DIR):\r
+ try:\r
+ os.makedirs(pkg_base.LOG_DIR)\r
+ logger.info("Made directory {}".format(pkg_base.LOG_DIR))\r
+ except Exception as exc:\r
+ logger.warning("Failed to create directory {} for log".format(\r
+ pkg_base.LOG_DIR))\r
+ icon.notify("Create {} manually".format((pkg_base.LOG_DIR[:40] + '...') \r
+ if len(pkg_base.LOG_DIR) > 40 else pkg_base.LOG_DIR),\r
+ "Failed to create log directory")\r
+ file_handler = logging.FileHandler(pkg_base.LOG_PATH)\r
+ file_handler.setFormatter(log_formatter)\r
+ file_handler.setLevel(log_level)\r
+ logger.addHandler(file_handler)\r
+\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.server").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
+ logger.debug("Starting system tray icon")\r
+ icon = MyIcon(PKG_NAME)\r
+ icon.icon = Image.open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.ico''')\r
+ icon.title = "{} {}".format(PKG_NAME, PKG_VERSION)\r
+ #refresh_menu(icon)\r
+ icon.visible = True\r
+ icon.run(setup=start_server)\r
+\r
+ # Exit when icon has stopped\r
+ sys.exit(0)\r
\r
if __name__ == "__main__":\r
start_interface()\r