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