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 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
+http_daemon = None\r
+global http_server\r
+http_server = None\r
global ws_daemon\r
-global STATE\r
-global STATE_DEFAULT\r
-global current_slideshow\r
-global interface_root\r
-global interface_thread\r
+ws_daemon = None\r
+global users\r
+users = set()\r
global logger\r
+logger = None\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
+refresh_daemon = None\r
global icon\r
-scheduler = None\r
-current_slideshow = None\r
-interface_root = None\r
-interface_thread = None\r
+icon = None\r
+global ppt_application\r
+ppt_application = None\r
+global ppt_presentations\r
+ppt_presentations = {}\r
+global disable_protected_attempted\r
+disable_protected_attempted = set()\r
+\r
+PKG_NAME = pkg_base.__name__\r
+PKG_VERSION = pkg_base.__version__\r
CONFIG_FILE = r'''..\ppt-control.ini'''\r
LOGFILE = r'''..\ppt-control.log'''\r
-REFRESH_INTERVAL = 2\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
-reset_ppt_button = None\r
-icon = None\r
-ws_stop_event = False\r
\r
\r
-class Handler(server.SimpleHTTPRequestHandler):\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
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 = config.prefs["Main"]["cache"] + "\\" + current_slideshow.name()\r
- except Exception as e:\r
- path = "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 = "black.jpg"\r
- return path\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((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
-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
- logger.debug("Notifying 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
+ pres = ppt_presentations[data["presentation"]]\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
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:\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:\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
else:\r
logger.error("Received unnsupported event: {}", data)\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 http_server\r
+ http_server = http_server.HTTPServer((config.prefs["HTTP"]["interface"], config.prefs.getint("HTTP", "port")), Handler)\r
+ 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
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
- time.sleep(0.5)\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
-def restart_ws():\r
- global ws_daemon\r
- global ws_stop_event\r
- if ws_daemon and not ws_stop_event:\r
- ws_stop_event = True\r
- logger.debug("Stopped WebSocket server")\r
- refresh_status()\r
- #ws_daemon = None\r
- time.sleep(2)\r
- #start_ws()\r
- refresh_status()\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
- def OnSlideShowNextSlide(self, *args):\r
- notify_state()\r
- logger.debug("Slide changed")\r
- current_slideshow.export_current_next()\r
-\r
- def OnSlideShowPrevSlide(self, *args):\r
- notify_state()\r
- logger.debug("Slide changed")\r
- current_slideshow.export_current_next()\r
-\r
-class Slideshow:\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
-\r
- if self.instance.SlideShowWindows.Count == 0:\r
- raise ValueError("PPT instance has no slideshow windows")\r
- self.view = self.instance.SlideShowWindows(1).View\r
-\r
- if self.instance.ActivePresentation is None:\r
- raise ValueError("PPT instance has no active presentation")\r
- self.presentation = self.instance.ActivePresentation\r
-\r
- self.blackwhite = blackwhite\r
-\r
- if config.prefs["Main"]["cache_init"]:\r
- self.export_all()\r
- else:\r
- self.export_current_next()\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
- events = win32com.client.WithEvents(win32com.client.GetActiveObject("Powerpoint.Application"), ApplicationEvents)\r
- logger.debug("Dispatched events")\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 - 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
- def unload(self):\r
- connect_ppt()\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 of 200 ms 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(0.2)\r
+ broadcast_presentation(ppt_presentations.pop(presentation.Name))\r
+ icon.notify("Disconnected from {}".format(presentation.Name), PKG_NAME)\r
\r
- def refresh(self):\r
- try:\r
- if self.instance is None:\r
- raise ValueError("PPT instance cannot be None")\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
- if self.instance.SlideShowWindows.Count == 0:\r
- raise ValueError("PPT instance has no slideshow windows")\r
- self.view = self.instance.SlideShowWindows(1).View\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
- if self.instance.ActivePresentation is None:\r
- raise ValueError("PPT instance has no active presentation")\r
- except:\r
- self.unload()\r
\r
- def total_slides(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
- self.refresh()\r
- return len(self.presentation.Slides)\r
- except (ValueError, pywintypes.com_error):\r
- self.unload()\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
- 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 state(self):\r
+ """\r
+ Returns the visibility state of the slideshow:\r
+ 2: normal\r
+ 3: black\r
+ 4: white\r
+ 5: done\r
+ """\r
+ if self.slideshow is not None:\r
+ return self.slideshow.View.State\r
+ else:\r
+ return 2\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_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 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
- 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
-\r
- def white(self):\r
- try:\r
- self.refresh()\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
+ """\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.\r
+ """\r
+ assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+ self.slideshow.View.State = 2\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 = config.prefs["Main"]["cache"] + "\\" + self.name() + "\\" + str(slide) + ".jpg"\r
- logger.debug("Exporting slide " + str(slide))\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
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
+ 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() + 2):\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
-\r
-def refresh_interval():\r
+def null_action(*args):\r
+ """\r
+ Placeholder for disabled menu items in systray\r
+ """\r
+ pass\r
+ \r
+def refresh():\r
+ """\r
+ Clear COM events and update interface elements at an interval defined in "refresh" in the config\r
+ """\r
while getattr(refresh_daemon, "do_run", True):\r
- logger.debug("Refreshing general")\r
- pythoncom.PumpWaitingMessages()\r
- current_slideshow.refresh()\r
- if current_slideshow.visible != STATE["visible"]:\r
- notify_state()\r
- #refresh_status()\r
- time.sleep(REFRESH_INTERVAL)\r
-\r
-def refresh_status():\r
- if interface_root is not None:\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, 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
- 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
- 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
- if interface_root is None:\r
- interface_root = tk.Tk()\r
- interface_root.protocol("WM_DELETE_WINDOW", on_closing)\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
- 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
+ pythoncom.PumpWaitingMessages()\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
+ 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
- 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
- global reset_ppt_button\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.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
\r
- #reset_ws_button = ttk.Button(self, text="Restart", command=restart_ws)\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
+ # Sometimes gets pywintypes.com_error "The object invoked has disconnected from its clients"\r
+ # at this point which leads to "Exception whilst dealing with protected view windows".\r
+ logger.warning("Exception whilst dealing with protected view windows: {}".format(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_action(icon):\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
- if interface_root is not None:\r
- try:\r
- interface_root.destroy()\r
- except:\r
- pass\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=True)\r
- )\r
-\r
-def show_icon():\r
- global icon\r
- logger.debug("Starting system tray icon")\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
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
\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 = pystray.Icon(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
+ start_interface()
\ No newline at end of file
var preloaded = false;
var preload = [];
-var prev = document.querySelector('#prev'),
+var presentation = document.querySelector('#presentation'),
+ prev = document.querySelector('#prev'),
next = document.querySelector('#next'),
first = document.querySelector('#first'),
last = document.querySelector('#last'),
current = document.querySelector('#current'),
total = document.querySelector('#total'),
status_text = document.querySelector('.status_text'),
+ presentation_text = document.querySelector('.presentation_text'),
current_img = document.querySelector('#current_img'),
next_img = document.querySelector('#next_img'),
current_div = document.querySelector('#current_div'),
show_next = document.querySelector('#show_next'),
shortcuts = document.querySelector('#shortcuts');
+var presentations_list = {};
+
+
+function getPresentationName() {
+ if (presentation.selectedOptions.length > 0) {
+ return presentation.selectedOptions[0].innerText;
+ } else {
+ return "";
+ }
+}
+
function startWebsocket() {
console.log("Attempting to connect")
var websocket = startWebsocket();
prev.onclick = function (event) {
- websocket.send(JSON.stringify({action: 'prev'}));
+ if (getPresentationName()) {
+ websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'prev'}));
+ }
}
next.onclick = function (event) {
- websocket.send(JSON.stringify({action: 'next'}));
+ if (getPresentationName()) {
+ websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'next'}));
+ }
}
first.onclick = function (event) {
- websocket.send(JSON.stringify({action: 'first'}));
+ if (getPresentationName()) {
+ websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'first'}));
+ }
}
last.onclick = function (event) {
- websocket.send(JSON.stringify({action: 'last'}));
+ if (getPresentationName()) {
+ websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'last'}));
+ }
}
black.onclick = function (event) {
- websocket.send(JSON.stringify({action: 'black'}));
+ if (getPresentationName()) {
+ websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'black'}));
+ }
}
white.onclick = function (event) {
- websocket.send(JSON.stringify({action: 'white'}));
+ if (getPresentationName()) {
+ websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'white'}));
+ }
}
current.onblur = function (event) {
- websocket.send(JSON.stringify({action: 'goto', value: current.value}));
+ if (getPresentationName()) {
+ websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'goto', value: current.value}));
+ }
}
current.addEventListener('keyup',function(e){
}
});
+function refreshInterface() {
+ var d = new Date;
+ currentPresentationData = presentations_list[getPresentationName()];
+ presentation_text.style.display = "block";
+ status_text.textContent = "Slideshow running";
+ if (show_current.checked) {
+ switch (currentPresentationData.visible) {
+ case 3:
+ current_img.src = "/black.jpg";
+ break;
+ case 4:
+ current_img.src = "/white.jpg";
+ break;
+ default:
+ current_img.src = "/cache/" + currentPresentationData.name + "/" + currentPresentationData.slide_current + ".jpg?t=" + d.getTime();
+ break;
+ }
+ }
+ if (currentPresentationData.slide_current == currentPresentationData.slide_total + 1) {
+ next_img.src = "/black.jpg";
+ } else {
+ next_img.src = "/cache/" + currentPresentationData.name + "/" + (currentPresentationData.slide_current + 1) + ".jpg?t=" + d.getTime();
+ }
+ if (currentPresentationData.slide_current == 0) {
+ current_img.src = "/black.jpg";
+ next_img.src = "/black.jpg";
+ status_text.textContent = "Slideshow not running";
+ }
+
+ if (document.activeElement != current) {
+ current.value = currentPresentationData.slide_current;
+ }
+ total.textContent = currentPresentationData.slide_total;
+ document.title = currentPresentationData.name;
+}
+
function disconnect() {
console.log("Disconnecting")
document.title = DEFAULT_TITLE;
status_text.textContent = "Disconnected";
total.textContent = "?";
current.value = "";
+ presentation_text.style.display = "none";
}
function receive_message(event) {
data = JSON.parse(event.data);
- switch (data.type) {
- case 'state':
- if (data.connected == "0" || data.connected == 0) {
- disconnect();
- break;
- } else {
- status_text.textContent = "Connected";
- }
- var d = new Date;
- if (show_current.checked) {
- switch (data.visible) {
- case 3:
- current_img.src = "/black.jpg";
- break;
- case 4:
- current_img.src = "/white.jpg";
- break;
- default:
- current_img.src = "/cache/" + data.current + ".jpg?t=" + d.getTime();
- //current_img.src = "/cache/" + data.current + ".jpg";
- break;
- }
- }
- if (data.current == data.total + 1) {
- next_img.src = "/cache/" + (data.total + 1) + ".jpg?t=" + d.getTime();
- //next_img.src = "/cache/" + (data.total + 1) + ".jpg";
- } else {
- next_img.src = "/cache/" + (data.current + 1) + ".jpg?t=" + d.getTime();
- //next_img.src = "/cache/" + (data.current + 1) + ".jpg";
- }
+ presentations_list[data.name] = {"name": data.name, "pres_open": data.pres_open, "slideshow": data.slideshow, "visible": data.visible, "slide_current": data.slide_current, "slide_total": data.slide_total};
+ if (! presentations_list[data.name].pres_open) {
+ delete presentations_list[data.name];
+ if (Object.keys(presentations_list).length == 0) {
+ disconnect();
+ } else {
+ status_text.textContent = "Connected";
+ }
+ } else {
+
+ var presentation_dropdown = Array.from(presentation.children);
- if (document.activeElement != current) {
- current.value = data.current;
+ var found = false;
+ for(var i = 0; i < presentation_dropdown.length; i++) {
+ if (presentation_dropdown[i].textContent == data.name) {
+ if (! data.pres_open) {
+ presentation_dropdown[i].remove()
+ } else {
+ found = true;
+ break;
+ }
}
- total.textContent = data.total;
- document.title = data.name;
- break;
- default:
- console.error(
- "Unsupported event", data);
+ }
+ if (found == false) {
+ var dropdown_option = document.createElement("option");
+ dropdown_option.textContent = data.name;
+ dropdown_option.value = data.name;
+ presentation.appendChild(dropdown_option);
+ }
+ refreshInterface()
}
+
if (preloaded == false && ! isNaN(total.textContent)) {
image = document.getElementById("preload_img");
for (let i=1; i<=Number(total.textContent); i++) {
- image.src = "/cache/" + i + ".jpg";
+ image.src = "/cache/" + getPresentationName() + "/" + i + ".jpg";
preload.push(image);
}
preloaded = true;