4218f5c9ebdc59839e300a67d05da18bd1ff1349
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 logger.debug("Refreshing UI")
434 if status_label is not None:
435 status_label.config(text="PowerPoint status: " + ("not " if not STATE["connected"] else "") + "connected")
436 if http_label is not None:
437 http_label.config(text="HTTP server: " + ("not " if http_server is None else "") + "running")
438 #ws_label.config(text="WebSocket server: " + ("not " if ws_daemon is not None or not ws_daemon.is_alive() else "") + "running")
439 if reset_ppt_button is not None:
440 reset_ppt_button.config(state = tk.DISABLED if not STATE["connected"] else tk.NORMAL)
441
442def connect_ppt():
443 global STATE
444 global refresh_daemon
445 if STATE["connected"] == 1:
446 logger.info("Disconnected from PowerPoint instance")
447 icon.notify("Disconnected from PowerPoint instance")
448 if reset_ppt_button is not None:
449 reset_ppt_button.config(state = tk.DISABLED)
450 refresh_daemon.do_run = False
451 STATE = copy(STATE_DEFAULT)
452 if icon is not None:
453 refresh_menu()
454 refresh_status()
455 logger.debug("State is now " + str(STATE))
456 while True:
457 try:
458 instance = get_ppt_instance()
459 global current_slideshow
460 current_slideshow = Slideshow(instance, config.prefs["Main"]["blackwhite"])
461 STATE["connected"] = 1
462 STATE["current"] = current_slideshow.current_slide()
463 STATE["total"] = current_slideshow.total_slides()
464 icon.notify("Connected to PowerPoint instance")
465 if icon is not None:
466 refresh_menu()
467 refresh_status()
468 logger.info("Connected to PowerPoint instance")
469 refresh_daemon = threading.Thread(name="refresh_daemon", target=refresh_interval)
470 refresh_daemon.setDaemon(True)
471 refresh_daemon.start()
472 break
473 except ValueError as e:
474 current_slideshow = None
475 pass
476 time.sleep(1)
477
478def start(_=None):
479 start_http()
480 start_ws()
481 connect_ppt()
482
483def on_closing():
484 global status_label
485 global http_label
486 global ws_label
487 global interface_thread
488 status_label = None
489 http_label = None
490 ws_label = None
491 logger.debug("Destroying interface root")
492 interface_root.destroy()
493 logger.debug("Destroying interface thread")
494 interface_thread.root.quit()
495 interface_thread = None
496
497def open_settings(_=None):
498 global interface_root
499 global interface_thread
500 interface_root = tk.Tk()
501 interface_root.protocol("WM_DELETE_WINDOW", on_closing)
502 interface_root.iconphoto(False, tk.PhotoImage(file=os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.png'''))
503 interface_root.geometry("600x300+300+300")
504 app = Interface(interface_root)
505 interface_thread = threading.Thread(target=interface_root.mainloop())
506 interface_thread.setDaemon(True)
507 interface_thread.start()
508
509def null_action():
510 pass
511
512def save_settings():
513 pass
514
515class Interface(ttk.Frame):
516
517 def __init__(self, parent):
518 ttk.Frame.__init__(self, parent)
519
520 self.parent = parent
521
522 self.initUI()
523
524 def initUI(self):
525 global status_label
526 global http_label
527 global ws_label
528 global reset_ppt_button
529 self.parent.title("ppt-control")
530 self.style = ttk.Style()
531 #self.style.theme_use("default")
532 self.focus_force()
533
534 self.pack(fill=tk.BOTH, expand=1)
535
536 quitButton = ttk.Button(self, text="Cancel", command=interface_root.destroy)
537 quitButton.place(x=480, y=280)
538
539 save_button = ttk.Button(self, text="OK", command=save_settings)
540 save_button.place(x=400, y=280)
541
542 reset_ppt_button = ttk.Button(self, text="Reconnect", command=connect_ppt)
543 reset_ppt_button.config(state = tk.DISABLED)
544 reset_ppt_button.place(x=300, y=10)
545
546 reset_http_button = ttk.Button(self, text="Restart", command=restart_http)
547 reset_http_button.place(x=300, y=30)
548
549 #reset_ws_button = ttk.Button(self, text="Restart", command=null_action)
550 #reset_ws_button.place(x=300, y=50)
551
552 status_label = ttk.Label(self)
553 status_label.place(x=10,y=10)
554
555 http_label = ttk.Label(self)
556 http_label.place(x=10,y=30)
557
558 ws_label = ttk.Label(self)
559 ws_label.place(x=10,y=50)
560
561 refresh_status()
562
563
564def exit_action(icon):
565 logger.debug("User requested shutdown")
566 icon.visible = False
567 icon.stop()
568
569def refresh_menu():
570 icon.menu = (pystray.MenuItem("Status: " + "dis"*(not STATE["connected"]) + "connected", lambda: null_action(), enabled=False),
571 pystray.MenuItem("Stop", lambda: exit_action(icon)),
572 pystray.MenuItem("Settings", lambda: open_settings(), enabled=False)
573 )
574
575def show_icon():
576 global icon
577 logger.debug("Starting system tray icon")
578 icon = pystray.Icon("ppt-control")
579 icon.icon = Image.open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.ico''')
580 icon.title = "ppt-control"
581 refresh_menu()
582 icon.visible = True
583 icon.run(setup=start)
584
585def start_interface():
586 global logger
587
588 # Load config
589 config.prefs = config.loadconf(CONFIG_FILE)
590
591 # Set up logging
592 if config.prefs["Main"]["logging"] == "debug":
593 log_level = logging.DEBUG
594 elif config.prefs["Main"]["logging"] == "info":
595 log_level = logging.CRITICAL
596 else:
597 log_level = logging.WARNING
598 log_level = logging.DEBUG
599
600 log_formatter = logging.Formatter("%(asctime)s [%(threadName)-12.12s] [%(levelname)-7.7s] %(message)s")
601 logger = logging.getLogger("ppt-control")
602 logger.setLevel(log_level)
603 logger.propagate = False
604
605 file_handler = logging.FileHandler("{0}/{1}".format(os.getenv("APPDATA"), LOGFILE))
606 file_handler.setFormatter(log_formatter)
607 file_handler.setLevel(log_level)
608 logger.addHandler(file_handler)
609
610 console_handler = logging.StreamHandler()
611 console_handler.setFormatter(log_formatter)
612 console_handler.setLevel(log_level)
613 logger.addHandler(console_handler)
614
615 #logging.getLogger("asyncio").setLevel(logging.ERROR)
616 #logging.getLogger("asyncio.coroutines").setLevel(logging.ERROR)
617 logging.getLogger("websockets.server").setLevel(logging.ERROR)
618 #logging.getLogger("websockets.protocol").setLevel(logging.ERROR)
619
620
621 logger.debug("Finished setting up config and logging")
622
623 # Start systray icon and server
624 show_icon()
625 sys.exit(0)
626
627if __name__ == "__main__":
628 start_interface()