1import sys
2sys.coinit_flags= 0
3import win32com.client
4import pywintypes
5import os
6import shutil
7#import http.server as server
8import socketserver
9import threading
10import asyncio
11import websockets
12import logging, logging.handlers
13import json
14import urllib
15import posixpath
16import time
17import pythoncom
18import pystray
19import tkinter as tk
20from tkinter import ttk
21from PIL import Image
22from copy import copy
23
24import ppt_control.http_server_39 as server
25import ppt_control.config as config
26
27logging.basicConfig()
28
29global http_daemon
30global ws_daemon
31global STATE
32global STATE_DEFAULT
33global current_slideshow
34global interface_root
35global logger
36global refresh_daemon
37global status_label
38global http_label
39global ws_label
40global reset_ppt_button
41global http_server
42scheduler = None
43current_slideshow = None
44interface_root = None
45CONFIG_FILE = r'''..\ppt-control.ini'''
46LOGFILE = r'''..\ppt-control.log'''
47logger = None
48refresh_daemon = None
49status_label = None
50http_label = None
51ws_label = None
52ws_daemon = None
53http_server = None
54reset_ppt_button = None
55
56
57class Handler(server.SimpleHTTPRequestHandler):
58 def __init__(self, *args, **kwargs):
59 super().__init__(*args, directory=os.path.dirname(os.path.realpath(__file__)) + r'''\static''')
60
61 def log_request(self, code='-', size='-'):
62 return
63
64
65 def translate_path(self, path):
66 """Translate a /-separated PATH to the local filename syntax.
67
68 Components that mean special things to the local file system
69 (e.g. drive or directory names) are ignored. (XXX They should
70 probably be diagnosed.)
71
72 """
73 # abandon query parameters
74 path = path.split('?',1)[0]
75 path = path.split('#',1)[0]
76 # Don't forget explicit trailing slash when normalizing. Issue17324
77 trailing_slash = path.rstrip().endswith('/')
78 try:
79 path = urllib.parse.unquote(path, errors='surrogatepass')
80 except UnicodeDecodeError:
81 path = urllib.parse.unquote(path)
82 path = posixpath.normpath(path)
83 words = path.split('/')
84 words = list(filter(None, words))
85 if len(words) > 0 and words[0] == "cache":
86 black = 0
87 if current_slideshow:
88 try:
89 path = config.prefs["Main"]["cache"] + "\\" + current_slideshow.name()
90 except Exception as e:
91 path = "black.jpg"
92 logger.warning("Failed to get current slideshow name: ", e)
93 else:
94 path = "black.jpg"
95 return path
96 words.pop(0)
97 else:
98 path = self.directory
99 for word in words:
100 if os.path.dirname(word) or word in (os.curdir, os.pardir):
101 # Ignore components that are not a simple file/directory name
102 continue
103 path = os.path.join(path, word)
104 if trailing_slash:
105 path += '/'
106 return path
107
108
109def run_http():
110 global http_server
111 http_server = server.HTTPServer((config.prefs["HTTP"]["interface"], config.prefs.getint("HTTP", "port")), Handler)
112 http_server.serve_forever()
113
114STATE_DEFAULT = {"connected": 0, "current": 0, "total": 0, "visible": 0, "name": ""}
115STATE = copy(STATE_DEFAULT)
116USERS = set()
117
118
119def state_event():
120 return json.dumps({"type": "state", **STATE})
121
122
123def notify_state():
124 global STATE
125 if current_slideshow and STATE["connected"] == 1:
126 try:
127 STATE["current"] = current_slideshow.current_slide()
128 STATE["total"] = current_slideshow.total_slides()
129 STATE["visible"] = current_slideshow.visible()
130 STATE["name"] = current_slideshow.name()
131 except Exception as e:
132 logger.info("Failed to update state variables, presumably PPT instance doesn't exist anymore: {}".format(e))
133 current_slideshow.unload()
134 else:
135 STATE = copy(STATE_DEFAULT)
136 if USERS: # asyncio.wait doesn't accept an empty list
137 message = state_event()
138 loop.call_soon_threadsafe(ws_queue.put_nowait, state_event())
139
140
141
142async def ws_handler(websocket, path):
143 logger.debug("Handling WebSocket connection")
144 recv_task = asyncio.ensure_future(ws_receive(websocket, path))
145 send_task = asyncio.ensure_future(ws_send(websocket, path))
146 done, pending = await asyncio.wait(
147 [recv_task, send_task],
148 return_when=asyncio.FIRST_COMPLETED,
149 )
150 for task in pending:
151 task.cancel()
152
153async def ws_receive(websocket, path):
154 logger.debug("Received websocket request")
155 USERS.add(websocket)
156 try:
157 # Send initial state to clients on load
158 notify_state()
159 async for message in websocket:
160 logger.debug("Received websocket message: " + str(message))
161 data = json.loads(message)
162 if data["action"] == "prev":
163 if current_slideshow:
164 current_slideshow.prev()
165 notify_state()
166 elif data["action"] == "next":
167 if current_slideshow:
168 current_slideshow.next()
169 notify_state()
170 elif data["action"] == "first":
171 if current_slideshow:
172 current_slideshow.first()
173 notify_state()
174 elif data["action"] == "last":
175 if current_slideshow:
176 current_slideshow.last()
177 notify_state()
178 elif data["action"] == "black":
179 if current_slideshow:
180 if current_slideshow.visible() == 3:
181 current_slideshow.normal()
182 else:
183 current_slideshow.black()
184 notify_state()
185 elif data["action"] == "white":
186 if current_slideshow:
187 if current_slideshow.visible() == 4:
188 current_slideshow.normal()
189 else:
190 current_slideshow.white()
191 notify_state()
192 elif data["action"] == "goto":
193 if current_slideshow:
194 current_slideshow.goto(int(data["value"]))
195 notify_state()
196 else:
197 logger.error("Received unnsupported event: {}", data)
198 finally:
199 USERS.remove(websocket)
200
201async def ws_send(websocket, path):
202 while True:
203 message = await ws_queue.get()
204 await asyncio.wait([user.send(message) for user in USERS])
205
206
207def run_ws():
208 # https://stackoverflow.com/questions/21141217/how-to-launch-win32-applications-in-separate-threads-in-python/22619084#22619084
209 # https://www.reddit.com/r/learnpython/comments/mwt4qi/pywintypescom_error_2147417842_the_application/
210 pythoncom.CoInitializeEx(pythoncom.COINIT_MULTITHREADED)
211 asyncio.set_event_loop(asyncio.new_event_loop())
212 global ws_queue
213 ws_queue = asyncio.Queue()
214 global loop
215 loop = asyncio.get_event_loop()
216 start_server = websockets.serve(ws_handler, config.prefs["WebSocket"]["interface"], config.prefs.getint("WebSocket", "port"), ping_interval=None)
217 asyncio.get_event_loop().run_until_complete(start_server)
218 asyncio.get_event_loop().run_forever()
219
220def start_http():
221 http_daemon = threading.Thread(name="http_daemon", target=run_http)
222 http_daemon.setDaemon(True)
223 http_daemon.start()
224 logger.info("Started HTTP server")
225
226def restart_http():
227 global http_server
228 if http_server:
229 http_server.shutdown()
230 http_server = None
231 refresh_status()
232 start_http()
233 time.sleep(0.5)
234 refresh_status()
235
236def start_ws():
237 global ws_daemon
238 ws_daemon = threading.Thread(name="ws_daemon", target=run_ws)
239 ws_daemon.setDaemon(True)
240 ws_daemon.start()
241 logger.info("Started websocket server")
242
243class Slideshow:
244 def __init__(self, instance, blackwhite):
245 self.instance = instance
246 if self.instance is None:
247 raise ValueError("PPT instance cannot be None")
248
249 if self.instance.SlideShowWindows.Count == 0:
250 raise ValueError("PPT instance has no slideshow windows")
251 self.view = self.instance.SlideShowWindows[0].View
252
253 if self.instance.ActivePresentation is None:
254 raise ValueError("PPT instance has no active presentation")
255 self.presentation = self.instance.ActivePresentation
256
257 self.blackwhite = blackwhite
258
259 self.export_current_next()
260
261 def unload(self):
262 connect_ppt()
263 reset_ppt_button.config(state = tk.DISABLED)
264
265 def refresh(self):
266 try:
267 if self.instance is None:
268 raise ValueError("PPT instance cannot be None")
269
270 if self.instance.SlideShowWindows.Count == 0:
271 raise ValueError("PPT instance has no slideshow windows")
272 self.view = self.instance.SlideShowWindows[0].View
273
274 if self.instance.ActivePresentation is None:
275 raise ValueError("PPT instance has no active presentation")
276 except:
277 self.unload()
278
279 def total_slides(self):
280 try:
281 self.refresh()
282 return len(self.presentation.Slides)
283 except (ValueError, pywintypes.com_error):
284 self.unload()
285
286 def current_slide(self):
287 try:
288 self.refresh()
289 return self.view.CurrentShowPosition
290 except (ValueError, pywintypes.com_error):
291 self.unload()
292
293 def visible(self):
294 try:
295 self.refresh()
296 return self.view.State
297 except (ValueError, pywintypes.com_error):
298 self.unload()
299
300 def prev(self):
301 try:
302 self.refresh()
303 self.view.Previous()
304 self.export_current_next()
305 except (ValueError, pywintypes.com_error):
306 self.unload()
307
308 def next(self):
309 try:
310 self.refresh()
311 self.view.Next()
312 self.export_current_next()
313 except (ValueError, pywintypes.com_error):
314 self.unload()
315
316 def first(self):
317 try:
318 self.refresh()
319 self.view.First()
320 self.export_current_next()
321 except (ValueError, pywintypes.com_error):
322 self.unload()
323
324 def last(self):
325 try:
326 self.refresh()
327 self.view.Last()
328 self.export_current_next()
329 except (ValueError, pywintypes.com_error):
330 self.unload()
331
332 def goto(self, slide):
333 try:
334 self.refresh()
335 if slide <= self.total_slides():
336 self.view.GotoSlide(slide)
337 else:
338 self.last()
339 self.next()
340 self.export_current_next()
341 except (ValueError, pywintypes.com_error):
342 self.unload()
343
344 def black(self):
345 try:
346 self.refresh()
347 if self.blackwhite == "both" and self.view.State == 4:
348 self.view.state = 1
349 else:
350 self.view.State = 3
351 self.export_current_next()
352 except (ValueError, pywintypes.com_error):
353 self.unload()
354
355 def white(self):
356 try:
357 self.refresh()
358 if self.blackwhite == "both" and self.view.State == 3:
359 self.view.state = 1
360 else:
361 self.view.State = 4
362 self.export_current_next()
363 except (ValueError, pywintypes.com_error):
364 self.unload()
365
366 def normal(self):
367 try:
368 self.refresh()
369 self.view.State = 1
370 self.export_current_next()
371 except (ValueError, pywintypes.com_error):
372 self.unload()
373
374 def name(self):
375 try:
376 self.refresh()
377 return self.presentation.Name
378 except (ValueError, pywintypes.com_error):
379 self.unload()
380
381
382 def export_current_next(self):
383 self.export(self.current_slide())
384 self.export(self.current_slide() + 1)
385 self.export(self.current_slide() + 2)
386
387 def export(self, slide):
388 destination = config.prefs["Main"]["cache"] + "\\" + self.name() + "\\" + str(slide) + ".jpg"
389 os.makedirs(os.path.dirname(destination), exist_ok=True)
390 if not os.path.exists(destination) or time.time() - os.path.getmtime(destination) > config.prefs.getint("Main", "cache_timeout"):
391 if slide <= self.total_slides():
392 attempts = 0
393 while attempts < 3:
394 try:
395 self.presentation.Slides(slide).Export(destination, config.prefs["Main"]["cache_format"])
396 break
397 except:
398 pass
399 attempts += 1
400 elif slide == self.total_slides() + 1:
401 try:
402 shutil.copyfileobj(open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\black.jpg''', 'rb'), open(destination, 'wb'))
403 except Exception as e:
404 logger.warning("Failed to copy black slide: " + str(e))
405 else:
406 pass
407
408 def export_all(self):
409 for i in range(1, self.total_slides()):
410 self.export(i)
411
412def get_ppt_instance():
413 instance = win32com.client.Dispatch('Powerpoint.Application')
414 if instance is None or instance.SlideShowWindows.Count == 0:
415 return None
416 return instance
417
418def get_current_slideshow():
419 return current_slideshow
420
421def refresh_interval():
422 while getattr(refresh_daemon, "do_run", True):
423 current_slideshow.refresh()
424 notify_state()
425 refresh_status()
426 time.sleep(0.5)
427
428def refresh_status():
429 if status_label is not None:
430 status_label.config(text="PowerPoint status: " + ("not " if not STATE["connected"] else "") + "connected")
431 http_label.config(text="HTTP server: " + ("not " if http_server is None else "") + "running")
432 #ws_label.config(text="WebSocket server: " + ("not " if ws_daemon is not None or not ws_daemon.is_alive() else "") + "running")
433 if reset_ppt_button is not None:
434 reset_ppt_button.config(state = tk.DISABLED if not STATE["connected"] else tk.NORMAL)
435
436def connect_ppt():
437 global STATE
438 global refresh_daemon
439 if STATE["connected"] == 1:
440 logger.info("Disconnected from PowerPoint instance")
441 refresh_daemon.do_run = False
442 STATE = copy(STATE_DEFAULT)
443 refresh_status()
444 logger.debug("State is now " + str(STATE))
445 while True:
446 try:
447 instance = get_ppt_instance()
448 global current_slideshow
449 current_slideshow = Slideshow(instance, config.prefs["Main"]["blackwhite"])
450 STATE["connected"] = 1
451 STATE["current"] = current_slideshow.current_slide()
452 STATE["total"] = current_slideshow.total_slides()
453 refresh_status()
454 logger.info("Connected to PowerPoint instance")
455 refresh_daemon = threading.Thread(name="refresh_daemon", target=refresh_interval)
456 refresh_daemon.setDaemon(True)
457 refresh_daemon.start()
458 break
459 except ValueError as e:
460 current_slideshow = None
461 pass
462 time.sleep(1)
463
464def start(_=None):
465 start_http()
466 start_ws()
467 connect_ppt()
468
469def on_closing():
470 global status_label
471 global http_label
472 global ws_label
473 status_label = None
474 http_label = None
475 ws_label = None
476 interface_root.destroy()
477
478def open_settings(_=None):
479 global interface_root
480 interface_root = tk.Tk()
481 interface_root.protocol("WM_DELETE_WINDOW", on_closing)
482 interface_root.iconphoto(False, tk.PhotoImage(file=os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.png'''))
483 interface_root.geometry("600x300+300+300")
484 app = Interface(interface_root)
485 interface_thread = threading.Thread(target=interface_root.mainloop())
486 interface_thread.setDaemon(True)
487 interface_thread.start()
488
489def null_action():
490 pass
491
492def save_settings():
493 pass
494
495class Interface(ttk.Frame):
496
497 def __init__(self, parent):
498 ttk.Frame.__init__(self, parent)
499
500 self.parent = parent
501
502 self.initUI()
503
504 def initUI(self):
505 global status_label
506 global http_label
507 global ws_label
508 global reset_ppt_button
509 self.parent.title("ppt-control")
510 self.style = ttk.Style()
511 #self.style.theme_use("default")
512 self.focus_force()
513
514 self.pack(fill=tk.BOTH, expand=1)
515
516 quitButton = ttk.Button(self, text="Cancel", command=interface_root.destroy)
517 quitButton.place(x=480, y=280)
518
519 save_button = ttk.Button(self, text="OK", command=save_settings)
520 save_button.place(x=400, y=280)
521
522 reset_ppt_button = ttk.Button(self, text="Reconnect", command=connect_ppt)
523 reset_ppt_button.config(state = tk.DISABLED)
524 reset_ppt_button.place(x=300, y=10)
525
526 reset_http_button = ttk.Button(self, text="Restart", command=restart_http)
527 reset_http_button.place(x=300, y=30)
528
529 reset_ws_button = ttk.Button(self, text="Restart", command=null_action)
530 reset_ws_button.place(x=300, y=50)
531
532 status_label = ttk.Label(self)
533 status_label.place(x=10,y=10)
534
535 http_label = ttk.Label(self)
536 http_label.place(x=10,y=30)
537
538 ws_label = ttk.Label(self)
539 ws_label.place(x=10,y=50)
540
541 refresh_status()
542
543
544def exit_action(icon):
545 logger.debug("User requested shutdown")
546 icon.visible = False
547 icon.stop()
548
549def show_icon():
550 logger.debug("Starting system tray icon")
551 icon = pystray.Icon("ppt-control")
552 icon.menu = (pystray.MenuItem("Status", lambda: null_action(), enabled=False),
553 pystray.MenuItem("Restart", lambda: start()),
554 pystray.MenuItem("Stop", lambda: exit_action(icon)),
555 pystray.MenuItem("Settings", lambda: open_settings())
556 )
557 icon.icon = Image.open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.ico''')
558 icon.title = "ppt-control"
559 icon.visible = True
560 icon.run(setup=start)
561
562def start_interface():
563 global logger
564
565 # Load config
566 config.prefs = config.loadconf(CONFIG_FILE)
567
568 # Set up logging
569 if config.prefs["Main"]["logging"] == "debug":
570 log_level = logging.DEBUG
571 elif config.prefs["Main"]["logging"] == "info":
572 log_level = logging.CRITICAL
573 else:
574 log_level = logging.WARNING
575 log_level = logging.DEBUG
576
577 log_formatter = logging.Formatter("%(asctime)s [%(threadName)-12.12s] [%(levelname)-7.7s] %(message)s")
578 logger = logging.getLogger("ppt-control")
579 logger.setLevel(log_level)
580 logger.propagate = False
581
582 file_handler = logging.FileHandler("{0}/{1}".format(os.getenv("APPDATA"), LOGFILE))
583 file_handler.setFormatter(log_formatter)
584 file_handler.setLevel(log_level)
585 logger.addHandler(file_handler)
586
587 console_handler = logging.StreamHandler()
588 console_handler.setFormatter(log_formatter)
589 console_handler.setLevel(log_level)
590 logger.addHandler(console_handler)
591
592 #logging.getLogger("asyncio").setLevel(logging.ERROR)
593 #logging.getLogger("asyncio.coroutines").setLevel(logging.ERROR)
594 logging.getLogger("websockets.server").setLevel(logging.ERROR)
595 #logging.getLogger("websockets.protocol").setLevel(logging.ERROR)
596
597
598 logger.debug("Finished setting up config and logging")
599
600 # Start systray icon and server
601 show_icon()
602 sys.exit(0)
603
604if __name__ == "__main__":
605 start_interface()