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()