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