ppt_control / ppt_control.pyon commit replace client-triggered state refresh with server-triggered (d75ca91)
   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()