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