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