ppt_control / ppt_control.pyon commit restructure project, add setuptools files (7cb045a)
   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 users_event():
 119    return json.dumps({"type": "users", "count": len(USERS)})
 120
 121
 122async def notify_state():
 123    global STATE
 124    if current_slideshow and STATE["connected"] == 1:
 125        try:
 126            STATE["current"] = current_slideshow.current_slide()
 127            STATE["total"] = current_slideshow.total_slides()
 128            STATE["visible"] = current_slideshow.visible()
 129            STATE["name"] = current_slideshow.name()
 130        except Exception as e:
 131            logger.info("Failed to update state variables, presumably PPT instance doesn't exist anymore: {}".format(e))
 132            current_slideshow.unload()
 133    else:
 134        STATE = copy(STATE_DEFAULT)
 135    if USERS:  # asyncio.wait doesn't accept an empty list
 136        message = state_event()
 137        await asyncio.wait([user.send(message) for user in USERS])
 138
 139
 140async def notify_users():
 141    if USERS:  # asyncio.wait doesn't accept an empty list
 142        message = users_event()
 143        await asyncio.wait([user.send(message) for user in USERS])
 144
 145
 146async def register(websocket):
 147    USERS.add(websocket)
 148    await notify_users()
 149
 150
 151async def unregister(websocket):
 152    USERS.remove(websocket)
 153    await notify_users()
 154
 155
 156async def ws_handle(websocket, path):
 157    logger.debug("Received websocket request")
 158    global current_slideshow
 159    # register(websocket) sends user_event() to websocket
 160    await register(websocket)
 161    try:
 162        await websocket.send(state_event())
 163        async for message in websocket:
 164            data = json.loads(message)
 165            if data["action"] == "prev":
 166                if current_slideshow:
 167                    current_slideshow.prev()
 168                await notify_state()
 169            elif data["action"] == "next":
 170                if current_slideshow:
 171                    current_slideshow.next()
 172                await notify_state()
 173            elif data["action"] == "first":
 174                if current_slideshow:
 175                    current_slideshow.first()
 176                await notify_state()
 177            elif data["action"] == "last":
 178                if current_slideshow:
 179                    current_slideshow.last()
 180                await notify_state()
 181            elif data["action"] == "black":
 182                if current_slideshow:
 183                    if current_slideshow.visible() == 3:
 184                        current_slideshow.normal()
 185                    else:
 186                        current_slideshow.black()
 187                await notify_state()
 188            elif data["action"] == "white":
 189                if current_slideshow:
 190                    if current_slideshow.visible() == 4:
 191                        current_slideshow.normal()
 192                    else:
 193                        current_slideshow.white()
 194                await notify_state()
 195            elif data["action"] == "goto":
 196                if current_slideshow:
 197                    current_slideshow.goto(int(data["value"]))
 198                await notify_state()
 199            elif data["action"] == "refresh":
 200                await notify_state()
 201                if current_slideshow:
 202                    current_slideshow.export_current_next()
 203                    current_slideshow.refresh()
 204            else:
 205                logger.error("unsupported event: {}", data)
 206    finally:
 207        await unregister(websocket)
 208
 209def run_ws():
 210    # https://stackoverflow.com/questions/21141217/how-to-launch-win32-applications-in-separate-threads-in-python/22619084#22619084
 211    # https://www.reddit.com/r/learnpython/comments/mwt4qi/pywintypescom_error_2147417842_the_application/
 212    pythoncom.CoInitializeEx(pythoncom.COINIT_MULTITHREADED)
 213    asyncio.set_event_loop(asyncio.new_event_loop())
 214    #start_server = websockets.serve(ws_handle, "0.0.0.0", 5678, ping_interval=None)
 215    start_server = websockets.serve(ws_handle, "0.0.0.0", 5678)
 216    asyncio.get_event_loop().run_until_complete(start_server)
 217    asyncio.get_event_loop().run_forever()
 218
 219def start_http():
 220    http_daemon = threading.Thread(name="http_daemon", target=run_http)
 221    http_daemon.setDaemon(True)
 222    http_daemon.start()
 223    logger.info("Started HTTP server")
 224
 225def restart_http():
 226    global http_server
 227    if http_server:
 228        http_server.shutdown()
 229        http_server = None
 230        refresh_status()
 231    start_http()
 232    refresh_status()
 233
 234def start_ws():
 235    global ws_daemon
 236    ws_daemon = threading.Thread(name="ws_daemon", target=run_ws)
 237    ws_daemon.setDaemon(True)
 238    ws_daemon.start()
 239    logger.info("Started websocket server")
 240
 241class Slideshow:
 242    def __init__(self, instance):
 243        self.instance = instance
 244        if self.instance is None:
 245            raise ValueError("PPT instance cannot be None")
 246
 247        if self.instance.SlideShowWindows.Count == 0:
 248            raise ValueError("PPT instance has no slideshow windows")
 249        self.view = self.instance.SlideShowWindows[0].View
 250
 251        if self.instance.ActivePresentation is None:
 252            raise ValueError("PPT instance has no  active presentation")
 253        self.presentation = self.instance.ActivePresentation
 254
 255        self.export_current_next()
 256
 257    def unload(self):
 258        connect_ppt()
 259
 260    def refresh(self):
 261        logger.debug("Refreshing")
 262        try:
 263            if self.instance is None:
 264                raise ValueError("PPT instance cannot be None")
 265
 266            if self.instance.SlideShowWindows.Count == 0:
 267                raise ValueError("PPT instance has no slideshow windows")
 268            self.view = self.instance.SlideShowWindows[0].View
 269
 270            if self.instance.ActivePresentation is None:
 271                raise ValueError("PPT instance has no  active presentation")
 272        except:
 273            self.unload()
 274
 275    def total_slides(self):
 276        try:
 277            self.refresh()
 278            return len(self.presentation.Slides)
 279        except (ValueError, pywintypes.com_error):
 280            self.unload()
 281
 282    def current_slide(self):
 283        try:
 284            self.refresh()
 285            return self.view.CurrentShowPosition
 286        except (ValueError, pywintypes.com_error):
 287            self.unload()
 288
 289    def visible(self):
 290        try:
 291            self.refresh()
 292            return self.view.State
 293        except (ValueError, pywintypes.com_error):
 294            self.unload()
 295
 296    def prev(self):
 297        try:
 298            self.refresh()
 299            self.view.Previous()
 300            self.export_current_next()
 301        except (ValueError, pywintypes.com_error):
 302            self.unload()
 303
 304    def next(self):
 305        try:
 306            self.refresh()
 307            self.view.Next()
 308            self.export_current_next()
 309        except (ValueError, pywintypes.com_error):
 310            self.unload()
 311
 312    def first(self):
 313        try:
 314            self.refresh()
 315            self.view.First()
 316            self.export_current_next()
 317        except (ValueError, pywintypes.com_error):
 318            self.unload()
 319                
 320    def last(self):
 321        try:
 322            self.refresh()
 323            self.view.Last()
 324            self.export_current_next()
 325        except (ValueError, pywintypes.com_error):
 326            self.unload()
 327
 328    def goto(self, slide):
 329        try:
 330            self.refresh()
 331            if slide <= self.total_slides():
 332                self.view.GotoSlide(slide)
 333            else:
 334                self.last()
 335                self.next()
 336            self.export_current_next()
 337        except (ValueError, pywintypes.com_error):
 338            self.unload()
 339
 340    def black(self):
 341        try:
 342            self.refresh()
 343            self.view.State = 3
 344            self.export_current_next()
 345        except (ValueError, pywintypes.com_error):
 346            self.unload()
 347
 348    def white(self):
 349        try:
 350            self.refresh()
 351            self.view.State = 4
 352            self.export_current_next()
 353        except (ValueError, pywintypes.com_error):
 354            self.unload()
 355
 356    def normal(self):
 357        try:
 358            self.refresh()
 359            self.view.State = 1
 360            self.export_current_next()
 361        except (ValueError, pywintypes.com_error):
 362            self.unload()
 363
 364    def name(self):
 365        try:
 366            self.refresh()
 367            return self.presentation.Name
 368        except (ValueError, pywintypes.com_error):
 369            self.unload()
 370
 371
 372    def export_current_next(self):
 373        self.export(self.current_slide())
 374        self.export(self.current_slide() + 1)
 375        self.export(self.current_slide() + 2)
 376
 377    def export(self, slide):
 378        destination = CACHEDIR + "\\" + self.name() + "\\" + str(slide) + ".jpg"
 379        os.makedirs(os.path.dirname(destination), exist_ok=True)
 380        if not os.path.exists(destination) or time.time() - os.path.getmtime(destination) > config.prefs.getint("Main", "cache_timeout"):
 381            if slide <= self.total_slides():
 382                attempts = 0
 383                while attempts < 3:
 384                    try:
 385                        self.presentation.Slides(slide).Export(destination, "JPG")
 386                        break
 387                    except:
 388                        pass
 389                    attempts += 1
 390            elif slide == self.total_slides() + 1:
 391                shutil.copyfileobj(open(os.path.dirname(os.path.realpath(__file__)), r'''\static\black.jpg''', 'rb'), open(destination, 'wb'))
 392            else:
 393                pass
 394
 395    def export_all(self):
 396        for i in range(1, self.total_slides()):
 397            self.export(i)
 398
 399def get_ppt_instance():
 400    instance = win32com.client.Dispatch('Powerpoint.Application')
 401    if instance is None or instance.SlideShowWindows.Count == 0:
 402        return None
 403    return instance
 404
 405def get_current_slideshow():
 406    return current_slideshow
 407
 408def refresh_interval():
 409    while getattr(refresh_daemon, "do_run", True):
 410        logger.debug("Triggering server-side refresh")
 411        current_slideshow.refresh()
 412        refresh_status()
 413        time.sleep(1)
 414
 415def refresh_status():
 416    if status_label is not None:
 417        status_label.config(text="PowerPoint status: " + ("not " if not STATE["connected"] else "") +  "connected")
 418        http_label.config(text="HTTP server: " + ("not " if http_server is None else "") +  "running")
 419        #ws_label.config(text="WebSocket server: " + ("not " if ws_daemon is not None or not ws_daemon.is_alive() else "") +  "running")
 420
 421def connect_ppt():
 422    global STATE
 423    global refresh_daemon
 424    if STATE["connected"] == 1:
 425        logger.info("Disconnected from PowerPoint instance")
 426        refresh_daemon.do_run = False
 427        STATE = copy(STATE_DEFAULT)
 428        refresh_status()
 429        logger.debug("State is now " + str(STATE))
 430    while True:
 431        try:
 432            instance = get_ppt_instance()
 433            global current_slideshow
 434            current_slideshow = Slideshow(instance)
 435            STATE["connected"] = 1
 436            STATE["current"] = current_slideshow.current_slide()
 437            STATE["total"] = current_slideshow.total_slides()
 438            refresh_status()
 439            logger.info("Connected to PowerPoint instance")
 440            refresh_daemon = threading.Thread(name="refresh_daemon", target=refresh_interval)
 441            refresh_daemon.setDaemon(True)
 442            refresh_daemon.start()
 443            break
 444        except ValueError as e:
 445            current_slideshow = None
 446            pass
 447        time.sleep(1)
 448
 449def start(_=None):
 450    start_http()
 451    start_ws()
 452    connect_ppt()
 453
 454def on_closing():
 455    global status_label
 456    global http_label
 457    global ws_label
 458    status_label = None
 459    http_label = None
 460    ws_label = None
 461    interface_root.destroy()
 462    
 463def open_settings(_=None):
 464    global interface_root
 465    interface_root = tk.Tk()
 466    interface_root.protocol("WM_DELETE_WINDOW", on_closing)
 467    interface_root.iconphoto(False, tk.PhotoImage(file="static/icons/ppt.png"))
 468    interface_root.geometry("600x300+300+300")
 469    app = Interface(interface_root)
 470    interface_thread = threading.Thread(target=interface_root.mainloop())
 471    interface_thread.setDaemon(True)
 472    interface_thread.start()
 473
 474def null_action():
 475    pass
 476
 477def save_settings():
 478    pass
 479
 480class Interface(ttk.Frame):
 481
 482    def __init__(self, parent):
 483        ttk.Frame.__init__(self, parent)
 484
 485        self.parent = parent
 486
 487        self.initUI()
 488
 489    def initUI(self):
 490        global status_label
 491        global http_label
 492        global ws_label
 493        self.parent.title("ppt-control")
 494        self.style = ttk.Style()
 495        #self.style.theme_use("default")
 496        self.focus_force()
 497
 498        self.pack(fill=tk.BOTH, expand=1)
 499
 500        quitButton = ttk.Button(self, text="Cancel", command=interface_root.destroy)
 501        quitButton.place(x=480, y=280)
 502
 503        save_button = ttk.Button(self, text="OK", command=save_settings)
 504        save_button.place(x=400, y=280)
 505
 506        reset_ppt_button = ttk.Button(self, text="Reconnect", command=connect_ppt)
 507        reset_ppt_button.place(x=300, y=10)
 508
 509        reset_http_button = ttk.Button(self, text="Restart", command=restart_http)
 510        reset_http_button.place(x=300, y=30)
 511
 512        reset_ws_button = ttk.Button(self, text="Restart", command=null_action)
 513        reset_ws_button.place(x=300, y=50)
 514
 515        status_label = ttk.Label(self)
 516        status_label.place(x=10,y=10)
 517
 518        http_label = ttk.Label(self)
 519        http_label.place(x=10,y=30)
 520
 521        ws_label = ttk.Label(self)
 522        ws_label.place(x=10,y=50)
 523
 524        refresh_status()
 525        
 526        
 527
 528def show_icon():
 529    logger.debug("Starting system tray icon")
 530    menu = (pystray.MenuItem("Status", lambda: null_action(), enabled=False),
 531            pystray.MenuItem("Restart", lambda: start()),
 532            pystray.MenuItem("Settings", lambda: open_settings()))
 533    icon = pystray.Icon("ppt-control", Image.open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.ico'''), "ppt-control", menu)
 534    icon.visible = True
 535    icon.run(setup=start)
 536
 537def start_interface():
 538    global logger
 539
 540    # Load config
 541    config.prefs = config.loadconf(CONFIG_FILE)
 542
 543    # Set up logging
 544    if config.prefs["Main"]["logging"] == "debug":
 545        log_level = logging.DEBUG
 546    elif config.prefs["Main"]["logging"] == "info":
 547        log_level = logging.CRITICAL
 548    else:
 549        log_level = logging.WARNING
 550    log_level = logging.DEBUG
 551
 552    log_formatter = logging.Formatter("%(asctime)s [%(threadName)-12.12s] [%(levelname)-7.7s]  %(message)s")
 553    logger = logging.getLogger("ppt-control")
 554    logger.setLevel(log_level)
 555    logger.propagate = False
 556
 557    file_handler = logging.FileHandler("{0}/{1}".format(os.getenv("APPDATA"), LOGFILE))
 558    file_handler.setFormatter(log_formatter)
 559    file_handler.setLevel(log_level)
 560    logger.addHandler(file_handler)
 561
 562    console_handler = logging.StreamHandler()
 563    console_handler.setFormatter(log_formatter)
 564    console_handler.setLevel(log_level)
 565    logger.addHandler(console_handler)
 566
 567    logging.getLogger("asyncio").setLevel(logging.ERROR)
 568    logging.getLogger("asyncio.coroutines").setLevel(logging.ERROR)
 569    logging.getLogger("websockets.server").setLevel(logging.ERROR)
 570    logging.getLogger("websockets.protocol").setLevel(logging.ERROR)
 571
 572
 573    logger.debug("Finished setting up config and logging")
 574
 575    # Start systray icon and server
 576    show_icon()
 577
 578if __name__ == "__main__":
 579    start_interface()