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