diff --git a/controllers/controller.py b/controllers/controller.py index 2d51750..c314d94 100644 --- a/controllers/controller.py +++ b/controllers/controller.py @@ -11,6 +11,9 @@ class Controller(): self.handler() return + def _state_handler(self): + return + # Registers a function for the controller class to call to tell BAPSicle to do something. def register_callback(self, callback: Callable): self.callbacks.append(callback) diff --git a/controllers/mattchbox_usb.py b/controllers/mattchbox_usb.py index 76aacd3..512648c 100644 --- a/controllers/mattchbox_usb.py +++ b/controllers/mattchbox_usb.py @@ -1,52 +1,105 @@ -from typing import List +from helpers.logging_manager import LoggingManager +from helpers.state_manager import StateManager +from typing import List, Optional from controllers.controller import Controller from multiprocessing import Queue import serial -import sys +import time from setproctitle import setproctitle -class MattchBox(Controller): - ser: serial.Serial - def __init__(self, player_to_q: List[Queue], player_from_q: List[Queue]): +class MattchBox(Controller): + ser: Optional[serial.Serial] + port: Optional[str] + next_port: Optional[str] + server_state: StateManager + logger: LoggingManager + + def __init__(self, server_to_q: List[Queue], server_from_q: List[Queue], state: StateManager): process_title = "ControllerHandler" setproctitle(process_title) + + self.ser = None + self.logger = LoggingManager("ControllerMattchBox") #current_process().name = process_title - # connect to serial port - self.ser = serial.serial_for_url("/dev/cu.usbserial-210", do_not_open=True) - self.ser.baudrate = 2400 + self.server_state = state # This is a copy, will not update :/ + # This doesn't run, the callback function gets lost due to state being a copy in the multiprocessing process. + #self.server_state.add_callback(self._state_handler) # Allow server config changes to trigger controller reload if required. + self.port = None + self.next_port = self.server_state.state["serial_port"] - # TOOD: These need to be split in the player handler. - self.player_from_q = player_from_q - self.player_to_q = player_to_q - - try: - self.ser.open() - except serial.SerialException as e: - sys.stderr.write('Could not open serial port {}: {}\n'.format(self.ser.name, e)) - return + self.server_from_q = server_from_q + self.server_to_q = server_to_q self.handler() + # This doesn't run, the callback function gets lost in StateManager. + def _state_handler(self): + new_port = self.server_state.state["serial_port"] + self.logger.log.info("Got server config update. New port: {}".format(new_port)) + if new_port != self.port: + self.logger.log.info("Switching from port {} to {}".format(self.port, new_port)) + # The serial port config has changed. Let's reload the serial. + self.port = None + self.next_port = new_port + + def connect(self, port: Optional[str]): + if port: + # connect to serial port + self.ser = serial.serial_for_url(port, do_not_open=True) + self.ser.baudrate = 2400 + try: + self.ser.open() + self.logger.log.info('Connected to serial port {}'.format(port)) + except serial.SerialException as e: + self.logger.log.error('Could not open serial port {}: {}'.format(port, e)) + self.ser = None + else: + self.ser = None + + def handler(self): while True: - try: - if self.ser.is_open: - line = int.from_bytes(self.ser.read(1), "big") # Endianness doesn't matter for 1 byte. - print("Controller got:", line) - if (line == 255): - print("Sending back KeepAlive") - self.ser.write(b'\xff') # Send 255 back. - elif (line in [1,3,5]): - self.sendToPlayer(int(line / 2), "PLAY") - elif (line in [2,4,6]): - self.sendToPlayer(int(line / 2)-1, "STOP") + if self.ser and self.ser.is_open and self.port: # If self.port is changing (via state_handler), we should stop. + try: + line = int.from_bytes(self.ser.read(1), "big") # Endianness doesn't matter for 1 byte. + self.logger.log.info("Received from controller:", line) + if (line == 255): + self.ser.write(b'\xff') # Send 255 back. + elif (line in [1,3,5]): + self.sendToPlayer(int(line / 2), "PLAY") + elif (line in [2,4,6]): + self.sendToPlayer(int(line / 2)-1, "STOP") + except: + continue + finally: + time.sleep(0.01) + + elif self.port: + # If there's still a port set, just wait a moment and see if it's been reconnected. + self.server_state.update("ser_connected", False) + time.sleep(10) + self.connect(self.port) + + else: + # We're not already connected, or a new port connection is to be made. + if self.ser: + self.ser.close() + self.server_state.update("ser_connected", False) + + if self.next_port != None: + self.connect(self.next_port) + if self.ser.is_open: + self.port = self.next_port # We connected successfully, make it stick. + self.server_state.update("ser_connected", True) + continue # skip the sleep. + time.sleep(10) + - except: - continue def sendToPlayer(self, channel: int, msg:str): - self.player_to_q[channel].put("CONTROLLER:" + msg) + self.logger.log.info("Sending message to server: " + msg) + self.server_to_q[channel].put("CONTROLLER:" + msg) diff --git a/helpers/device_manager.py b/helpers/device_manager.py index 2c146f9..d529946 100644 --- a/helpers/device_manager.py +++ b/helpers/device_manager.py @@ -1,8 +1,7 @@ -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional import sounddevice as sd -from helpers.os_environment import isMacOS - - +from helpers.os_environment import isLinux, isMacOS, isWindows +import glob class DeviceManager(): @classmethod @@ -24,3 +23,35 @@ class DeviceManager(): outputs: List[Dict] = list(filter(cls._isOutput, cls._getAudioDevices())) outputs = sorted(outputs, key=lambda k: k['name']) return [{"name": None}] + outputs + + @classmethod + def getSerialPorts(cls) -> List[Optional[str]]: + """ Lists serial port names + + :raises EnvironmentError: + On unsupported or unknown platforms + :returns: + A list of the serial ports available on the system + """ + # TODO: Get list of COM ports properly. (Can't use ) + if isWindows(): + ports = ['COM%s' % (i + 1) for i in range(8)] + elif isLinux(): + # this excludes your current terminal "/dev/tty" + ports = glob.glob('/dev/tty[A-Za-z]*') + elif isMacOS(): + ports = glob.glob('/dev/tty.*') + else: + raise EnvironmentError('Unsupported platform') + + valid: List[str] = ports + + result: List[Optional[str]] = [] + + if len(valid) > 0: + valid.sort() + + result.append(None) # Add the None option + result.extend(valid) + + return result diff --git a/helpers/state_manager.py b/helpers/state_manager.py index 89c5eed..3cd3da4 100644 --- a/helpers/state_manager.py +++ b/helpers/state_manager.py @@ -15,8 +15,8 @@ from helpers.types import ServerState from typing import Any, Dict, List, NewType, Optional, Union class StateManager: - filepath = None - logger = None + filepath: str + logger: LoggingManager callbacks: List[Any] = [] __state = {} __state_in_file = {} @@ -148,6 +148,7 @@ class StateManager: self.logger.log.critical("Failed to execute status callback: {}".format(e)) def add_callback(self, function): + self._log("Adding callback: {}".format(str(function))) self.callbacks.append(function) def _log(self, text:str, level: int = INFO): diff --git a/server.py b/server.py index 36ec92d..1239c54 100644 --- a/server.py +++ b/server.py @@ -16,7 +16,6 @@ from api_handler import APIHandler from controllers.mattchbox_usb import MattchBox import multiprocessing import queue -#import threading import time import player from flask import Flask, render_template, send_from_directory, request, jsonify, abort @@ -61,8 +60,6 @@ class BAPSicleServer(): state.update("server_version", config.VERSION) startServer() - #asyncio.get_event_loop().run_until_complete(startServer()) - #asyncio.get_event_loop().run_forever() def __del__(self): stopServer() @@ -75,18 +72,15 @@ default_state = { "host": "localhost", "port": 13500, "ws_port": 13501, - "num_channels": 3 + "num_channels": 3, + "ser_port": None, + "ser_connected": False, } app = Flask(__name__, static_url_path='') -CORS(app, supports_credentials=True) # Allow ALL CORS!!! -log = logging.getLogger('werkzeug') -log.disabled = True - -app.logger.disabled = True api_from_q: queue.Queue api_to_q: queue.Queue @@ -171,26 +165,27 @@ def server_config(): data = { "ui_page": "server", "ui_title": "Server Config", - "state": state.state + "state": state.state, + "ser_ports": DeviceManager.getSerialPorts() } return render_template("server.html", data=data) -@app.route("/restart", methods=["POST"]) -async def restart_server(): +@app.route("/server/update", methods=["POST"]) +def update_server(): state.update("server_name", request.form["name"]) state.update("host", request.form["host"]) state.update("port", int(request.form["port"])) state.update("num_channels", int(request.form["channels"])) state.update("ws_port", int(request.form["ws_port"])) - stopServer(restart=True) - await startServer() + state.update("serial_port", request.form["serial_port"]) + #stopServer() + return server_config() # Get audio for UI to generate waveforms. @app.route("/audiofile//") -#@cross_origin() def audio_file(type: str, id: int): if type not in ["managed", "track"]: abort(404) @@ -334,10 +329,6 @@ def list_showplans(): response = api_from_q.get_nowait() if response.startswith("LIST_PLANS:"): response = response[response.index(":")+1:] - #try: - # response = json.loads(response) - #except Exception as e: - # raise e return response except queue.Empty: @@ -539,11 +530,12 @@ def startServer(): player_handler = multiprocessing.Process(target=PlayerHandler, args=(channel_from_q, websocket_to_q, ui_to_q, controller_to_q)) player_handler.start() + # Note, state here will become a copy in the process. + # It will not update, and callbacks will not work :/ websockets_server = multiprocessing.Process(target=WebsocketServer, args=(channel_to_q, websocket_to_q, state)) websockets_server.start() - - controller_handler = multiprocessing.Process(target=MattchBox, args=(channel_to_q, controller_to_q)) + controller_handler = multiprocessing.Process(target=MattchBox, args=(channel_to_q, controller_to_q, state)) controller_handler.start() # TODO Move this to player or installer. @@ -579,19 +571,33 @@ def startServer(): channel_to_q[0].put("PLAY") # Don't use reloader, it causes Nested Processes! + def runWebServer(): + CORS(app, supports_credentials=True) # Allow ALL CORS!!! + + log = logging.getLogger('werkzeug') + log.disabled = True + + app.logger.disabled = True + app.run(host=state.state["host"], port=state.state["port"], debug=True, use_reloader=False) + global webserver webserver = multiprocessing.Process( - app.run(host=state.state["host"], port=state.state["port"], debug=True, use_reloader=False) + runWebServer() ) webserver.start() -def stopServer(restart=False): - global channel_p, channel_from_q, channel_to_q, websockets_server, webserver +def stopServer(): + global channel_p, channel_from_q, channel_to_q, websockets_server, webserver, controller_handler + print("Stopping Controllers") + controller_handler.terminate() + controller_handler.join() + print("Stopping Websockets") websocket_to_q[0].put("WEBSOCKET:QUIT") websockets_server.join() del websockets_server + print("Stopping server.py") for q in channel_to_q: q.put("QUIT") @@ -607,21 +613,13 @@ def stopServer(restart=False): del channel_to_q print("Stopped all players.") + print("Stopping webserver") global webserver webserver.terminate() webserver.join() - return - ## Caused an outside context error, presuably because called outside of a page request. - #shutdown = request.environ.get('werkzeug.server.shutdown') - #if shutdown is None: - # print("Shutting down Server.") - - #else: - # print("Shutting down Flask.") - # if not restart: - # shutdown() + print("Stopped webserver") if __name__ == "__main__": - print("BAPSicle is a service. Please run it like one.") + raise Exception("BAPSicle is a service. Please run it like one.") diff --git a/templates/config.html b/templates/config.html index c35127c..5745cd1 100644 --- a/templates/config.html +++ b/templates/config.html @@ -7,7 +7,7 @@ Set for: {% for channel in data.channels %} - Channel {{channel.channel}} + Channel {{channel.channel}} {% endfor %} - {% if output.name %}{{output.name}}{% else %}System Default Output{% endif %}
{% endfor %} diff --git a/templates/server.html b/templates/server.html index 3020c3f..8a65f90 100644 --- a/templates/server.html +++ b/templates/server.html @@ -1,7 +1,7 @@ {% extends 'base.html' %} {% block content_inner %} {% if data %} -
+
@@ -17,15 +17,12 @@
- - {% for port in data.ser_ports %} - + {% endfor %} - - -