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