ppt_control / ppt_control.pyon commit fix UI threading bugs (5c11a82)
   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        if status_label is not None:
 434            status_label.config(text="PowerPoint status: " + ("not " if not STATE["connected"] else "") +  "connected")
 435        if http_label is not None:
 436            http_label.config(text="HTTP server: " + ("not " if http_server is None else "") +  "running")
 437            #ws_label.config(text="WebSocket server: " + ("not " if ws_daemon is not None or not ws_daemon.is_alive() else "") +  "running")
 438        if reset_ppt_button is not None:
 439            reset_ppt_button.config(state = tk.DISABLED if not STATE["connected"] else tk.NORMAL)
 440
 441def connect_ppt():
 442    global STATE
 443    global refresh_daemon
 444    if STATE["connected"] == 1:
 445        logger.info("Disconnected from PowerPoint instance")
 446        icon.notify("Disconnected from PowerPoint instance")
 447        if reset_ppt_button is not None:
 448            reset_ppt_button.config(state = tk.DISABLED)
 449        refresh_daemon.do_run = False
 450        STATE = copy(STATE_DEFAULT)
 451        if icon is not None:
 452            refresh_menu()
 453        refresh_status()
 454        logger.debug("State is now " + str(STATE))
 455    while True:
 456        try:
 457            instance = get_ppt_instance()
 458            global current_slideshow
 459            current_slideshow = Slideshow(instance, config.prefs["Main"]["blackwhite"])
 460            STATE["connected"] = 1
 461            STATE["current"] = current_slideshow.current_slide()
 462            STATE["total"] = current_slideshow.total_slides()
 463            icon.notify("Connected to PowerPoint instance")
 464            if icon is not None:
 465                refresh_menu()
 466            refresh_status()
 467            logger.info("Connected to PowerPoint instance")
 468            refresh_daemon = threading.Thread(name="refresh_daemon", target=refresh_interval)
 469            refresh_daemon.setDaemon(True)
 470            refresh_daemon.start()
 471            break
 472        except ValueError as e:
 473            current_slideshow = None
 474            pass
 475        time.sleep(1)
 476
 477def start(_=None):
 478    start_http()
 479    start_ws()
 480    connect_ppt()
 481
 482def on_closing():
 483    global status_label
 484    global http_label
 485    global ws_label
 486    global interface_thread
 487    status_label = None
 488    http_label = None
 489    ws_label = None
 490    logger.debug("Destroying interface root")
 491    interface_root.destroy()
 492    logger.debug("Destroying interface thread")
 493    interface_thread.root.quit()
 494    interface_thread = None
 495    
 496def open_settings(_=None):
 497    global interface_root
 498    global interface_thread
 499    interface_root = tk.Tk()
 500    interface_root.protocol("WM_DELETE_WINDOW", on_closing)
 501    interface_root.iconphoto(False, tk.PhotoImage(file=os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.png'''))
 502    interface_root.geometry("600x300+300+300")
 503    app = Interface(interface_root)
 504    interface_thread = threading.Thread(target=interface_root.mainloop())
 505    interface_thread.setDaemon(True)
 506    interface_thread.start()
 507
 508def null_action():
 509    pass
 510
 511def save_settings():
 512    pass
 513
 514class Interface(ttk.Frame):
 515
 516    def __init__(self, parent):
 517        ttk.Frame.__init__(self, parent)
 518
 519        self.parent = parent
 520
 521        self.initUI()
 522
 523    def initUI(self):
 524        global status_label
 525        global http_label
 526        global ws_label
 527        global reset_ppt_button
 528        self.parent.title("ppt-control")
 529        self.style = ttk.Style()
 530        #self.style.theme_use("default")
 531        self.focus_force()
 532
 533        self.pack(fill=tk.BOTH, expand=1)
 534
 535        quitButton = ttk.Button(self, text="Cancel", command=interface_root.destroy)
 536        quitButton.place(x=480, y=280)
 537
 538        save_button = ttk.Button(self, text="OK", command=save_settings)
 539        save_button.place(x=400, y=280)
 540
 541        reset_ppt_button = ttk.Button(self, text="Reconnect", command=connect_ppt)
 542        reset_ppt_button.config(state = tk.DISABLED)
 543        reset_ppt_button.place(x=300, y=10)
 544
 545        reset_http_button = ttk.Button(self, text="Restart", command=restart_http)
 546        reset_http_button.place(x=300, y=30)
 547
 548        reset_ws_button = ttk.Button(self, text="Restart", command=null_action)
 549        reset_ws_button.place(x=300, y=50)
 550
 551        status_label = ttk.Label(self)
 552        status_label.place(x=10,y=10)
 553
 554        http_label = ttk.Label(self)
 555        http_label.place(x=10,y=30)
 556
 557        ws_label = ttk.Label(self)
 558        ws_label.place(x=10,y=50)
 559
 560        refresh_status()
 561        
 562        
 563def exit_action(icon):
 564    logger.debug("User requested shutdown")
 565    icon.visible = False
 566    icon.stop()
 567
 568def refresh_menu():
 569    icon.menu = (pystray.MenuItem("Status: " + "dis"*(not STATE["connected"]) + "connected", lambda: null_action(), enabled=False),
 570            pystray.MenuItem("Stop", lambda: exit_action(icon)),
 571            pystray.MenuItem("Settings", lambda: open_settings())
 572            )
 573
 574def show_icon():
 575    global icon
 576    logger.debug("Starting system tray icon")
 577    icon = pystray.Icon("ppt-control")
 578    icon.icon = Image.open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.ico''')
 579    icon.title = "ppt-control"
 580    refresh_menu()
 581    icon.visible = True
 582    icon.run(setup=start)
 583
 584def start_interface():
 585    global logger
 586
 587    # Load config
 588    config.prefs = config.loadconf(CONFIG_FILE)
 589
 590    # Set up logging
 591    if config.prefs["Main"]["logging"] == "debug":
 592        log_level = logging.DEBUG
 593    elif config.prefs["Main"]["logging"] == "info":
 594        log_level = logging.CRITICAL
 595    else:
 596        log_level = logging.WARNING
 597    log_level = logging.DEBUG
 598
 599    log_formatter = logging.Formatter("%(asctime)s [%(threadName)-12.12s] [%(levelname)-7.7s]  %(message)s")
 600    logger = logging.getLogger("ppt-control")
 601    logger.setLevel(log_level)
 602    logger.propagate = False
 603
 604    file_handler = logging.FileHandler("{0}/{1}".format(os.getenv("APPDATA"), LOGFILE))
 605    file_handler.setFormatter(log_formatter)
 606    file_handler.setLevel(log_level)
 607    logger.addHandler(file_handler)
 608
 609    console_handler = logging.StreamHandler()
 610    console_handler.setFormatter(log_formatter)
 611    console_handler.setLevel(log_level)
 612    logger.addHandler(console_handler)
 613
 614    #logging.getLogger("asyncio").setLevel(logging.ERROR)
 615    #logging.getLogger("asyncio.coroutines").setLevel(logging.ERROR)
 616    logging.getLogger("websockets.server").setLevel(logging.ERROR)
 617    #logging.getLogger("websockets.protocol").setLevel(logging.ERROR)
 618
 619
 620    logger.debug("Finished setting up config and logging")
 621
 622    # Start systray icon and server
 623    show_icon()
 624    sys.exit(0)
 625
 626if __name__ == "__main__":
 627    start_interface()