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