6b0f8181ef68edd6bb72188e894b2026440cf5ac
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 users_event():
119 return json.dumps({"type": "users", "count": len(USERS)})
120
121
122async def notify_state():
123 global STATE
124 if current_slideshow and STATE["connected"] == 1:
125 try:
126 STATE["current"] = current_slideshow.current_slide()
127 STATE["total"] = current_slideshow.total_slides()
128 STATE["visible"] = current_slideshow.visible()
129 STATE["name"] = current_slideshow.name()
130 except Exception as e:
131 logger.info("Failed to update state variables, presumably PPT instance doesn't exist anymore: {}".format(e))
132 current_slideshow.unload()
133 else:
134 STATE = copy(STATE_DEFAULT)
135 if USERS: # asyncio.wait doesn't accept an empty list
136 message = state_event()
137 await asyncio.wait([user.send(message) for user in USERS])
138
139
140async def notify_users():
141 if USERS: # asyncio.wait doesn't accept an empty list
142 message = users_event()
143 await asyncio.wait([user.send(message) for user in USERS])
144
145
146async def register(websocket):
147 USERS.add(websocket)
148 await notify_users()
149
150
151async def unregister(websocket):
152 USERS.remove(websocket)
153 await notify_users()
154
155
156async def ws_handle(websocket, path):
157 logger.debug("Received websocket request")
158 global current_slideshow
159 # register(websocket) sends user_event() to websocket
160 await register(websocket)
161 try:
162 await websocket.send(state_event())
163 async for message in websocket:
164 data = json.loads(message)
165 if data["action"] == "prev":
166 if current_slideshow:
167 current_slideshow.prev()
168 await notify_state()
169 elif data["action"] == "next":
170 if current_slideshow:
171 current_slideshow.next()
172 await notify_state()
173 elif data["action"] == "first":
174 if current_slideshow:
175 current_slideshow.first()
176 await notify_state()
177 elif data["action"] == "last":
178 if current_slideshow:
179 current_slideshow.last()
180 await notify_state()
181 elif data["action"] == "black":
182 if current_slideshow:
183 if current_slideshow.visible() == 3:
184 current_slideshow.normal()
185 else:
186 current_slideshow.black()
187 await notify_state()
188 elif data["action"] == "white":
189 if current_slideshow:
190 if current_slideshow.visible() == 4:
191 current_slideshow.normal()
192 else:
193 current_slideshow.white()
194 await notify_state()
195 elif data["action"] == "goto":
196 if current_slideshow:
197 current_slideshow.goto(int(data["value"]))
198 await notify_state()
199 elif data["action"] == "refresh":
200 await notify_state()
201 if current_slideshow:
202 current_slideshow.export_current_next()
203 current_slideshow.refresh()
204 else:
205 logger.error("unsupported event: {}", data)
206 finally:
207 await unregister(websocket)
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 #start_server = websockets.serve(ws_handle, "0.0.0.0", 5678, ping_interval=None)
215 start_server = websockets.serve(ws_handle, "0.0.0.0", 5678)
216 asyncio.get_event_loop().run_until_complete(start_server)
217 asyncio.get_event_loop().run_forever()
218
219def start_http():
220 http_daemon = threading.Thread(name="http_daemon", target=run_http)
221 http_daemon.setDaemon(True)
222 http_daemon.start()
223 logger.info("Started HTTP server")
224
225def restart_http():
226 global http_server
227 if http_server:
228 http_server.shutdown()
229 http_server = None
230 refresh_status()
231 start_http()
232 refresh_status()
233
234def start_ws():
235 global ws_daemon
236 ws_daemon = threading.Thread(name="ws_daemon", target=run_ws)
237 ws_daemon.setDaemon(True)
238 ws_daemon.start()
239 logger.info("Started websocket server")
240
241class Slideshow:
242 def __init__(self, instance):
243 self.instance = instance
244 if self.instance is None:
245 raise ValueError("PPT instance cannot be None")
246
247 if self.instance.SlideShowWindows.Count == 0:
248 raise ValueError("PPT instance has no slideshow windows")
249 self.view = self.instance.SlideShowWindows[0].View
250
251 if self.instance.ActivePresentation is None:
252 raise ValueError("PPT instance has no active presentation")
253 self.presentation = self.instance.ActivePresentation
254
255 self.export_current_next()
256
257 def unload(self):
258 connect_ppt()
259
260 def refresh(self):
261 logger.debug("Refreshing")
262 try:
263 if self.instance is None:
264 raise ValueError("PPT instance cannot be None")
265
266 if self.instance.SlideShowWindows.Count == 0:
267 raise ValueError("PPT instance has no slideshow windows")
268 self.view = self.instance.SlideShowWindows[0].View
269
270 if self.instance.ActivePresentation is None:
271 raise ValueError("PPT instance has no active presentation")
272 except:
273 self.unload()
274
275 def total_slides(self):
276 try:
277 self.refresh()
278 return len(self.presentation.Slides)
279 except (ValueError, pywintypes.com_error):
280 self.unload()
281
282 def current_slide(self):
283 try:
284 self.refresh()
285 return self.view.CurrentShowPosition
286 except (ValueError, pywintypes.com_error):
287 self.unload()
288
289 def visible(self):
290 try:
291 self.refresh()
292 return self.view.State
293 except (ValueError, pywintypes.com_error):
294 self.unload()
295
296 def prev(self):
297 try:
298 self.refresh()
299 self.view.Previous()
300 self.export_current_next()
301 except (ValueError, pywintypes.com_error):
302 self.unload()
303
304 def next(self):
305 try:
306 self.refresh()
307 self.view.Next()
308 self.export_current_next()
309 except (ValueError, pywintypes.com_error):
310 self.unload()
311
312 def first(self):
313 try:
314 self.refresh()
315 self.view.First()
316 self.export_current_next()
317 except (ValueError, pywintypes.com_error):
318 self.unload()
319
320 def last(self):
321 try:
322 self.refresh()
323 self.view.Last()
324 self.export_current_next()
325 except (ValueError, pywintypes.com_error):
326 self.unload()
327
328 def goto(self, slide):
329 try:
330 self.refresh()
331 if slide <= self.total_slides():
332 self.view.GotoSlide(slide)
333 else:
334 self.last()
335 self.next()
336 self.export_current_next()
337 except (ValueError, pywintypes.com_error):
338 self.unload()
339
340 def black(self):
341 try:
342 self.refresh()
343 self.view.State = 3
344 self.export_current_next()
345 except (ValueError, pywintypes.com_error):
346 self.unload()
347
348 def white(self):
349 try:
350 self.refresh()
351 self.view.State = 4
352 self.export_current_next()
353 except (ValueError, pywintypes.com_error):
354 self.unload()
355
356 def normal(self):
357 try:
358 self.refresh()
359 self.view.State = 1
360 self.export_current_next()
361 except (ValueError, pywintypes.com_error):
362 self.unload()
363
364 def name(self):
365 try:
366 self.refresh()
367 return self.presentation.Name
368 except (ValueError, pywintypes.com_error):
369 self.unload()
370
371
372 def export_current_next(self):
373 self.export(self.current_slide())
374 self.export(self.current_slide() + 1)
375 self.export(self.current_slide() + 2)
376
377 def export(self, slide):
378 destination = CACHEDIR + "\\" + self.name() + "\\" + str(slide) + ".jpg"
379 os.makedirs(os.path.dirname(destination), exist_ok=True)
380 if not os.path.exists(destination) or time.time() - os.path.getmtime(destination) > config.prefs.getint("Main", "cache_timeout"):
381 if slide <= self.total_slides():
382 attempts = 0
383 while attempts < 3:
384 try:
385 self.presentation.Slides(slide).Export(destination, "JPG")
386 break
387 except:
388 pass
389 attempts += 1
390 elif slide == self.total_slides() + 1:
391 shutil.copyfileobj(open(os.path.dirname(os.path.realpath(__file__)), r'''\static\black.jpg''', 'rb'), open(destination, 'wb'))
392 else:
393 pass
394
395 def export_all(self):
396 for i in range(1, self.total_slides()):
397 self.export(i)
398
399def get_ppt_instance():
400 instance = win32com.client.Dispatch('Powerpoint.Application')
401 if instance is None or instance.SlideShowWindows.Count == 0:
402 return None
403 return instance
404
405def get_current_slideshow():
406 return current_slideshow
407
408def refresh_interval():
409 while getattr(refresh_daemon, "do_run", True):
410 logger.debug("Triggering server-side refresh")
411 current_slideshow.refresh()
412 refresh_status()
413 time.sleep(1)
414
415def refresh_status():
416 if status_label is not None:
417 status_label.config(text="PowerPoint status: " + ("not " if not STATE["connected"] else "") + "connected")
418 http_label.config(text="HTTP server: " + ("not " if http_server is None else "") + "running")
419 #ws_label.config(text="WebSocket server: " + ("not " if ws_daemon is not None or not ws_daemon.is_alive() else "") + "running")
420
421def connect_ppt():
422 global STATE
423 global refresh_daemon
424 if STATE["connected"] == 1:
425 logger.info("Disconnected from PowerPoint instance")
426 refresh_daemon.do_run = False
427 STATE = copy(STATE_DEFAULT)
428 refresh_status()
429 logger.debug("State is now " + str(STATE))
430 while True:
431 try:
432 instance = get_ppt_instance()
433 global current_slideshow
434 current_slideshow = Slideshow(instance)
435 STATE["connected"] = 1
436 STATE["current"] = current_slideshow.current_slide()
437 STATE["total"] = current_slideshow.total_slides()
438 refresh_status()
439 logger.info("Connected to PowerPoint instance")
440 refresh_daemon = threading.Thread(name="refresh_daemon", target=refresh_interval)
441 refresh_daemon.setDaemon(True)
442 refresh_daemon.start()
443 break
444 except ValueError as e:
445 current_slideshow = None
446 pass
447 time.sleep(1)
448
449def start(_=None):
450 start_http()
451 start_ws()
452 connect_ppt()
453
454def on_closing():
455 global status_label
456 global http_label
457 global ws_label
458 status_label = None
459 http_label = None
460 ws_label = None
461 interface_root.destroy()
462
463def open_settings(_=None):
464 global interface_root
465 interface_root = tk.Tk()
466 interface_root.protocol("WM_DELETE_WINDOW", on_closing)
467 interface_root.iconphoto(False, tk.PhotoImage(file="static/icons/ppt.png"))
468 interface_root.geometry("600x300+300+300")
469 app = Interface(interface_root)
470 interface_thread = threading.Thread(target=interface_root.mainloop())
471 interface_thread.setDaemon(True)
472 interface_thread.start()
473
474def null_action():
475 pass
476
477def save_settings():
478 pass
479
480class Interface(ttk.Frame):
481
482 def __init__(self, parent):
483 ttk.Frame.__init__(self, parent)
484
485 self.parent = parent
486
487 self.initUI()
488
489 def initUI(self):
490 global status_label
491 global http_label
492 global ws_label
493 self.parent.title("ppt-control")
494 self.style = ttk.Style()
495 #self.style.theme_use("default")
496 self.focus_force()
497
498 self.pack(fill=tk.BOTH, expand=1)
499
500 quitButton = ttk.Button(self, text="Cancel", command=interface_root.destroy)
501 quitButton.place(x=480, y=280)
502
503 save_button = ttk.Button(self, text="OK", command=save_settings)
504 save_button.place(x=400, y=280)
505
506 reset_ppt_button = ttk.Button(self, text="Reconnect", command=connect_ppt)
507 reset_ppt_button.place(x=300, y=10)
508
509 reset_http_button = ttk.Button(self, text="Restart", command=restart_http)
510 reset_http_button.place(x=300, y=30)
511
512 reset_ws_button = ttk.Button(self, text="Restart", command=null_action)
513 reset_ws_button.place(x=300, y=50)
514
515 status_label = ttk.Label(self)
516 status_label.place(x=10,y=10)
517
518 http_label = ttk.Label(self)
519 http_label.place(x=10,y=30)
520
521 ws_label = ttk.Label(self)
522 ws_label.place(x=10,y=50)
523
524 refresh_status()
525
526
527
528def show_icon():
529 logger.debug("Starting system tray icon")
530 menu = (pystray.MenuItem("Status", lambda: null_action(), enabled=False),
531 pystray.MenuItem("Restart", lambda: start()),
532 pystray.MenuItem("Settings", lambda: open_settings()))
533 icon = pystray.Icon("ppt-control", Image.open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.ico'''), "ppt-control", menu)
534 icon.visible = True
535 icon.run(setup=start)
536
537def start_interface():
538 global logger
539
540 # Load config
541 config.prefs = config.loadconf(CONFIG_FILE)
542
543 # Set up logging
544 if config.prefs["Main"]["logging"] == "debug":
545 log_level = logging.DEBUG
546 elif config.prefs["Main"]["logging"] == "info":
547 log_level = logging.CRITICAL
548 else:
549 log_level = logging.WARNING
550 log_level = logging.DEBUG
551
552 log_formatter = logging.Formatter("%(asctime)s [%(threadName)-12.12s] [%(levelname)-7.7s] %(message)s")
553 logger = logging.getLogger("ppt-control")
554 logger.setLevel(log_level)
555 logger.propagate = False
556
557 file_handler = logging.FileHandler("{0}/{1}".format(os.getenv("APPDATA"), LOGFILE))
558 file_handler.setFormatter(log_formatter)
559 file_handler.setLevel(log_level)
560 logger.addHandler(file_handler)
561
562 console_handler = logging.StreamHandler()
563 console_handler.setFormatter(log_formatter)
564 console_handler.setLevel(log_level)
565 logger.addHandler(console_handler)
566
567 logging.getLogger("asyncio").setLevel(logging.ERROR)
568 logging.getLogger("asyncio.coroutines").setLevel(logging.ERROR)
569 logging.getLogger("websockets.server").setLevel(logging.ERROR)
570 logging.getLogger("websockets.protocol").setLevel(logging.ERROR)
571
572
573 logger.debug("Finished setting up config and logging")
574
575 # Start systray icon and server
576 show_icon()
577
578if __name__ == "__main__":
579 start_interface()