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