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