From 22876c846386a2fc3c9f8f7b733496217e92d6f8 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Fri, 4 Feb 2022 00:22:11 -0500 Subject: [PATCH 01/56] Combine reader and writer threads --- webui/server/server.py | 288 +++++++++++++++++------------------------ 1 file changed, 121 insertions(+), 167 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index c41b53b..0a7b7b5 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -50,26 +50,23 @@ def __init__(self, filename='profiles.txt'): self.cur_profile = '' # Have a default no-name profile we can use in case there are no profiles. self.profiles[''] = [0] * num_panels - self.loaded = False - - def MaybeLoad(self): - if not self.loaded: - num_profiles = 0 - if os.path.exists(self.filename): - with open(self.filename, 'r') as f: - for line in f: - parts = line.split() - if len(parts) == (num_panels+1): - self.profiles[parts[0]] = [int(x) for x in parts[1:]] - num_profiles += 1 - # Change to the first profile found. - # This will also emit the thresholds. - if num_profiles == 1: - self.ChangeProfile(parts[0]) - else: - open(self.filename, 'w').close() - self.loaded = True - print('Found Profiles: ' + str(list(self.profiles.keys()))) + + def Load(self): + num_profiles = 0 + if os.path.exists(self.filename): + with open(self.filename, 'r') as f: + for line in f: + parts = line.split() + if len(parts) == (num_panels+1): + self.profiles[parts[0]] = [int(x) for x in parts[1:]] + num_profiles += 1 + # Change to the first profile found. + # This will also emit the thresholds. + if num_profiles == 1: + self.ChangeProfile(parts[0]) + else: + open(self.filename, 'w').close() + print('Found Profiles: ' + str(list(self.profiles.keys()))) def GetCurThresholds(self): if self.cur_profile in self.profiles: @@ -133,13 +130,42 @@ def RemoveProfile(self, profile_name): def GetCurrentProfile(self): return self.cur_profile +profile_handler = ProfileHandler() +write_queue = queue.Queue(10) + +def run_fake_serial(write_queue, profile_handler): + # Use this to store the values when emulating serial so the graph isn't too + # jumpy. Only used when NO_SERIAL is true. + no_serial_values = [0] * num_panels -class SerialHandler(object): + while not thread_stop_event.is_set(): + # Check for command from write_queue + try: + # The timeout here controls the frequency of checking sensor values. + command = write_queue.get(timeout=0.01) + except queue.Empty: + # If there is no other pending command, check sensor values. + command = 'v\n' + if command == 'v\n': + offsets = [int(normalvariate(0, num_panels+1)) for _ in range(num_panels)] + no_serial_values = [ + max(0, min(no_serial_values[i] + offsets[i], 1023)) + for i in range(num_panels) + ] + broadcast(['values', {'values': no_serial_values}]) + elif command == 't\n': + if command[0] == 't': + broadcast(['thresholds', + {'thresholds': profile_handler.GetCurThresholds()}]) + print('Thresholds are: ' + + str(profile_handler.GetCurThresholds())) + + +def run_serial(port, timeout, write_queue, profile_handler): """ - A class to handle all the serial interactions. + A function to handle all the serial interactions. Run in a separate thread. - Attributes: - ser: Serial, the serial object opened by this class. + Parameters: port: string, the path/name of the serial object to open. timeout: int, the time in seconds indicating the timeout for serial operations. @@ -147,147 +173,81 @@ class SerialHandler(object): profile_handler: ProfileHandler, the global profile_handler used to update the thresholds """ - def __init__(self, profile_handler, port='', timeout=1): - self.ser = None - self.port = port - self.timeout = timeout - self.write_queue = queue.Queue(10) - self.profile_handler = profile_handler - - # Use this to store the values when emulating serial so the graph isn't too - # jumpy. Only used when NO_SERIAL is true. - self.no_serial_values = [0] * num_panels - - def ChangePort(self, port): - if self.ser: - self.ser.close() - self.ser = None - self.port = port - self.Open() - - def Open(self): - if not self.port: - return - - if self.ser: - self.ser.close() - self.ser = None - + ser = None + + def ProcessValues(values): + # Fix our sensor ordering. + actual = [] + for i in range(num_panels): + actual.append(values[sensor_numbers[i]]) + broadcast(['values', {'values': actual}]) + + def ProcessThresholds(values): + cur_thresholds = profile_handler.GetCurThresholds() + # Fix our sensor ordering. + actual = [] + for i in range(num_panels): + actual.append(values[sensor_numbers[i]]) + for i, (cur, act) in enumerate(zip(cur_thresholds, actual)): + if cur != act: + profile_handler.UpdateThresholds(i, act) + + while not thread_stop_event.is_set(): + # Try to open the serial port if needed + if not ser: + try: + ser = serial.Serial(port, 115200, timeout=timeout) + except serial.SerialException as e: + ser = None + logger.exception('Error opening serial: %s', e) + # Delay and retry + time.sleep(1) + continue + # Check for command from write_queue + try: + # The timeout here controls the frequency of checking sensor values. + command = write_queue.get(timeout=0.01) + except queue.Empty: + # If there is no other pending command, check sensor values. + command = 'v\n' try: - self.ser = serial.Serial(self.port, 115200, timeout=self.timeout) - if self.ser: - # Apply currently loaded thresholds when the microcontroller connects. - for i, threshold in enumerate(self.profile_handler.GetCurThresholds()): - threshold_cmd = str(sensor_numbers[i]) + str(threshold) + '\n' - self.write_queue.put(threshold_cmd, block=False) - except queue.Full as e: - logger.error('Could not set thresholds. Queue full.') + ser.write(command.encode()) except serial.SerialException as e: - self.ser = None - logger.exception('Error opening serial: %s', e) - - def Read(self): - def ProcessValues(values): - # Fix our sensor ordering. - actual = [] - for i in range(num_panels): - actual.append(values[sensor_numbers[i]]) - broadcast(['values', {'values': actual}]) - time.sleep(0.01) - - def ProcessThresholds(values): - cur_thresholds = self.profile_handler.GetCurThresholds() - # Fix our sensor ordering. - actual = [] - for i in range(num_panels): - actual.append(values[sensor_numbers[i]]) - for i, (cur, act) in enumerate(zip(cur_thresholds, actual)): - if cur != act: - self.profile_handler.UpdateThresholds(i, act) - - while not thread_stop_event.isSet(): - if NO_SERIAL: - offsets = [int(normalvariate(0, num_panels+1)) for _ in range(num_panels)] - self.no_serial_values = [ - max(0, min(self.no_serial_values[i] + offsets[i], 1023)) - for i in range(num_panels) - ] - broadcast(['values', {'values': self.no_serial_values}]) - time.sleep(0.01) - else: - if not self.ser: - self.Open() - # Still not open, retry loop. - if not self.ser: - time.sleep(1) - continue + logger.error('Error writing data: ', e) + # Maybe we need to surface the error higher up? + continue + try: + # Wait for a response. + # This will block the thread until it gets a newline or until the serial timeout. + line = ser.readline().decode('ascii') - try: - # Send the command to fetch the values. - self.write_queue.put('v\n', block=False) + if not line.endswith("\n"): + logger.error('Timeout reading response to command.', command, line) + continue - # Wait until we actually get the values. - # This will block the thread until it gets a newline - line = self.ser.readline().decode('ascii').strip() + line = line.strip() - # All commands are of the form: - # cmd num1 num2 num3 num4 - parts = line.split() - if len(parts) != num_panels+1: - continue - cmd = parts[0] - values = [int(x) for x in parts[1:]] - - if cmd == 'v': - ProcessValues(values) - elif cmd == 't': - ProcessThresholds(values) - except queue.Full as e: - logger.error('Could not fetch new values. Queue full.') - except serial.SerialException as e: - logger.error('Error reading data: ', e) - self.Open() - - def Write(self): - while not thread_stop_event.isSet(): - try: - command = self.write_queue.get(timeout=1) - except queue.Empty: + # All commands are of the form: + # cmd num1 num2 num3 num4 + parts = line.split() + if len(parts) != num_panels+1: continue - if NO_SERIAL: - if command[0] == 't': - broadcast(['thresholds', - {'thresholds': self.profile_handler.GetCurThresholds()}]) - print('Thresholds are: ' + - str(self.profile_handler.GetCurThresholds())) - else: - sensor, threshold = int(command[0]), int(command[1:-1]) - for i, index in enumerate(sensor_numbers): - if index == sensor: - self.profile_handler.UpdateThresholds(i, threshold) - else: - if not self.ser: - # Just wait until the reader opens the serial port. - time.sleep(1) - continue - - try: - self.ser.write(command.encode()) - except serial.SerialException as e: - logger.error('Error writing data: ', e) - # Emit current thresholds since we couldn't update the values. - broadcast(['thresholds', - {'thresholds': self.profile_handler.GetCurThresholds()}]) + cmd = parts[0] + values = [int(x) for x in parts[1:]] + if cmd == 'v': + ProcessValues(values) + elif cmd == 't': + ProcessThresholds(values) + except serial.SerialException as e: + logger.error('Error reading data: ', e) -profile_handler = ProfileHandler() -serial_handler = SerialHandler(profile_handler, port=SERIAL_PORT) def update_threshold(values, index): try: # Let the writer thread handle updating thresholds. threshold_cmd = str(sensor_numbers[index]) + str(values[index]) + '\n' - serial_handler.write_queue.put(threshold_cmd, block=False) + write_queue.put(threshold_cmd, block=False) except queue.Full: logger.error('Could not update thresholds. Queue full.') @@ -342,18 +302,10 @@ async def get_ws(request): request.app['websockets'].append(ws) print('Client connected') - profile_handler.MaybeLoad() - - # The above does emit if there are differences, so have an extra for the - # case there are no differences. - await ws.send_json([ - 'thresholds', - {'thresholds': profile_handler.GetCurThresholds()}, - ]) - # Potentially fetch any threshold values from the microcontroller that - # may be out of sync with our profiles. - serial_handler.write_queue.put('t\n', block=False) + # # Potentially fetch any threshold values from the microcontroller that + # # may be out of sync with our profiles. + # serial_handler.write_queue.put('t\n', block=False) queue = asyncio.Queue(maxsize=100) with out_queues_lock: @@ -422,11 +374,13 @@ async def get_index(request): return web.FileResponse(os.path.join(build_dir, 'index.html')) async def on_startup(app): - read_thread = threading.Thread(target=serial_handler.Read) - read_thread.start() + profile_handler.Load() - write_thread = threading.Thread(target=serial_handler.Write) - write_thread.start() + if NO_SERIAL: + serial_thread = threading.Thread(target=run_fake_serial, kwargs={'write_queue': write_queue, 'profile_handler': profile_handler}) + else: + serial_thread = threading.Thread(target=run_serial, kwargs={'port': SERIAL_PORT, 'timeout': 0.05, 'write_queue': write_queue, 'profile_handler': profile_handler}) + serial_thread.start() async def on_shutdown(app): for ws in app['websockets']: From 3ab7ca16f0f7dba1c6e41be44736b069ea912965 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Fri, 4 Feb 2022 20:58:22 -0500 Subject: [PATCH 02/56] Move more initialization to end of server.py --- webui/server/server.py | 73 ++++++++++++++++++++---------------------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 0a7b7b5..c39e0bd 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -43,11 +43,13 @@ class ProfileHandler(object): cur_profile: string, the name of the current active profile. loaded: bool, whether or not the backend has already loaded the profile data file or not. + broadcast: function to send commands """ - def __init__(self, filename='profiles.txt'): + def __init__(self, broadcast, filename='profiles.txt'): self.filename = filename self.profiles = OrderedDict() self.cur_profile = '' + self.broadcast = broadcast # Have a default no-name profile we can use in case there are no profiles. self.profiles[''] = [0] * num_panels @@ -84,14 +86,14 @@ def UpdateThresholds(self, index, value): for name, thresholds in self.profiles.items(): if name: f.write(name + ' ' + ' '.join(map(str, thresholds)) + '\n') - broadcast(['thresholds', {'thresholds': self.GetCurThresholds()}]) + self.broadcast(['thresholds', {'thresholds': self.GetCurThresholds()}]) print('Thresholds are: ' + str(self.GetCurThresholds())) def ChangeProfile(self, profile_name): if profile_name in self.profiles: self.cur_profile = profile_name - broadcast(['thresholds', {'thresholds': self.GetCurThresholds()}]) - broadcast(['get_cur_profile', {'cur_profile': self.GetCurrentProfile()}]) + self.broadcast(['thresholds', {'thresholds': self.GetCurThresholds()}]) + self.broadcast(['get_cur_profile', {'cur_profile': self.GetCurrentProfile()}]) print('Changed to profile "{}" with thresholds: {}'.format( self.GetCurrentProfile(), str(self.GetCurThresholds()))) @@ -108,7 +110,7 @@ def AddProfile(self, profile_name, thresholds): for name, thresholds in self.profiles.items(): if name: f.write(name + ' ' + ' '.join(map(str, thresholds)) + '\n') - broadcast(['get_profiles', {'profiles': self.GetProfileNames()}]) + self.broadcast(['get_profiles', {'profiles': self.GetProfileNames()}]) print('Added profile "{}" with thresholds: {}'.format( self.GetCurrentProfile(), str(self.GetCurThresholds()))) @@ -121,19 +123,16 @@ def RemoveProfile(self, profile_name): for name, thresholds in self.profiles.items(): if name: f.write(name + ' ' + ' '.join(map(str, thresholds)) + '\n') - broadcast(['get_profiles', {'profiles': self.GetProfileNames()}]) - broadcast(['thresholds', {'thresholds': self.GetCurThresholds()}]) - broadcast(['get_cur_profile', {'cur_profile': self.GetCurrentProfile()}]) + self.broadcast(['get_profiles', {'profiles': self.GetProfileNames()}]) + self.broadcast(['thresholds', {'thresholds': self.GetCurThresholds()}]) + self.broadcast(['get_cur_profile', {'cur_profile': self.GetCurrentProfile()}]) print('Removed profile "{}". Current thresholds are: {}'.format( profile_name, str(self.GetCurThresholds()))) def GetCurrentProfile(self): return self.cur_profile -profile_handler = ProfileHandler() -write_queue = queue.Queue(10) - -def run_fake_serial(write_queue, profile_handler): +def run_fake_serial(write_queue, broadcast, profile_handler): # Use this to store the values when emulating serial so the graph isn't too # jumpy. Only used when NO_SERIAL is true. no_serial_values = [0] * num_panels @@ -161,7 +160,7 @@ def run_fake_serial(write_queue, profile_handler): str(profile_handler.GetCurThresholds())) -def run_serial(port, timeout, write_queue, profile_handler): +def run_serial(port, timeout, write_queue, broadcast, profile_handler): """ A function to handle all the serial interactions. Run in a separate thread. @@ -170,6 +169,7 @@ def run_serial(port, timeout, write_queue, profile_handler): timeout: int, the time in seconds indicating the timeout for serial operations. write_queue: Queue, a queue object read by the writer thread + broadcast: function to send commands profile_handler: ProfileHandler, the global profile_handler used to update the thresholds """ @@ -281,21 +281,6 @@ async def get_defaults(request): 'thresholds': profile_handler.GetCurThresholds() }) - -out_queues = set() -out_queues_lock = threading.Lock() -main_thread_loop = asyncio.get_event_loop() - - -def broadcast(msg): - with out_queues_lock: - for q in out_queues: - try: - main_thread_loop.call_soon_threadsafe(q.put_nowait, msg) - except asyncio.queues.QueueFull: - pass - - async def get_ws(request): ws = web.WebSocketResponse() await ws.prepare(request) @@ -373,15 +358,6 @@ async def get_ws(request): async def get_index(request): return web.FileResponse(os.path.join(build_dir, 'index.html')) -async def on_startup(app): - profile_handler.Load() - - if NO_SERIAL: - serial_thread = threading.Thread(target=run_fake_serial, kwargs={'write_queue': write_queue, 'profile_handler': profile_handler}) - else: - serial_thread = threading.Thread(target=run_serial, kwargs={'port': SERIAL_PORT, 'timeout': 0.05, 'write_queue': write_queue, 'profile_handler': profile_handler}) - serial_thread.start() - async def on_shutdown(app): for ws in app['websockets']: await ws.close(code=WSCloseCode.GOING_AWAY, message='Server shutdown') @@ -403,9 +379,30 @@ async def on_shutdown(app): web.static('/', build_dir), ]) app.on_shutdown.append(on_shutdown) -app.on_startup.append(on_startup) if __name__ == '__main__': + out_queues = set() + out_queues_lock = threading.Lock() + main_thread_loop = asyncio.get_event_loop() + + def broadcastXX(msg): + with out_queues_lock: + for q in out_queues: + try: + main_thread_loop.call_soon_threadsafe(q.put_nowait, msg) + except asyncio.queues.QueueFull: + pass + + profile_handler = ProfileHandler(broadcastXX) + profile_handler.Load() + write_queue = queue.Queue(10) + + if NO_SERIAL: + serial_thread = threading.Thread(target=run_fake_serial, kwargs={'write_queue': write_queue, 'broadcast': broadcastXX, 'profile_handler': profile_handler}) + else: + serial_thread = threading.Thread(target=run_serial, kwargs={'port': SERIAL_PORT, 'timeout': 0.05, 'write_queue': write_queue, 'broadcast': broadcastXX, 'profile_handler': profile_handler}) + serial_thread.start() + hostname = socket.gethostname() ip_address = socket.gethostbyname(hostname) print(' * WebUI can be found at: http://' + ip_address + ':' + str(HTTP_PORT)) From 4cf5d3e0c4bd52a4924789e5ba85a2aef7ba78b4 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Fri, 4 Feb 2022 21:56:18 -0500 Subject: [PATCH 03/56] Handle websocket closing better --- webui/server/server.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index c39e0bd..0fa5ba7 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -313,7 +313,7 @@ async def get_ws(request): await ws.send_json(msg) queue_task = asyncio.create_task(queue.get()) - elif task == receive_task: + elif task == receive_task and not ws.closed: msg = await receive_task if msg.type == WSMsgType.TEXT: @@ -343,11 +343,9 @@ async def get_ws(request): request.app['websockets'].remove(ws) with out_queues_lock: out_queues.remove(queue) - - queue_task.cancel() - receive_task.cancel() - - print('Client disconnected') + queue_task.cancel() + receive_task.cancel() + print('Client disconnected') build_dir = os.path.abspath( From 290c6f34202ed8efd8ea3e376697c523d23f4dda Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Fri, 4 Feb 2022 23:00:38 -0500 Subject: [PATCH 04/56] Put broadcast back where it was --- webui/server/server.py | 43 ++++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 0fa5ba7..8454ddd 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -43,13 +43,11 @@ class ProfileHandler(object): cur_profile: string, the name of the current active profile. loaded: bool, whether or not the backend has already loaded the profile data file or not. - broadcast: function to send commands """ - def __init__(self, broadcast, filename='profiles.txt'): + def __init__(self, filename='profiles.txt'): self.filename = filename self.profiles = OrderedDict() self.cur_profile = '' - self.broadcast = broadcast # Have a default no-name profile we can use in case there are no profiles. self.profiles[''] = [0] * num_panels @@ -86,14 +84,14 @@ def UpdateThresholds(self, index, value): for name, thresholds in self.profiles.items(): if name: f.write(name + ' ' + ' '.join(map(str, thresholds)) + '\n') - self.broadcast(['thresholds', {'thresholds': self.GetCurThresholds()}]) + broadcast(['thresholds', {'thresholds': self.GetCurThresholds()}]) print('Thresholds are: ' + str(self.GetCurThresholds())) def ChangeProfile(self, profile_name): if profile_name in self.profiles: self.cur_profile = profile_name - self.broadcast(['thresholds', {'thresholds': self.GetCurThresholds()}]) - self.broadcast(['get_cur_profile', {'cur_profile': self.GetCurrentProfile()}]) + broadcast(['thresholds', {'thresholds': self.GetCurThresholds()}]) + broadcast(['get_cur_profile', {'cur_profile': self.GetCurrentProfile()}]) print('Changed to profile "{}" with thresholds: {}'.format( self.GetCurrentProfile(), str(self.GetCurThresholds()))) @@ -110,7 +108,7 @@ def AddProfile(self, profile_name, thresholds): for name, thresholds in self.profiles.items(): if name: f.write(name + ' ' + ' '.join(map(str, thresholds)) + '\n') - self.broadcast(['get_profiles', {'profiles': self.GetProfileNames()}]) + broadcast(['get_profiles', {'profiles': self.GetProfileNames()}]) print('Added profile "{}" with thresholds: {}'.format( self.GetCurrentProfile(), str(self.GetCurThresholds()))) @@ -123,9 +121,9 @@ def RemoveProfile(self, profile_name): for name, thresholds in self.profiles.items(): if name: f.write(name + ' ' + ' '.join(map(str, thresholds)) + '\n') - self.broadcast(['get_profiles', {'profiles': self.GetProfileNames()}]) - self.broadcast(['thresholds', {'thresholds': self.GetCurThresholds()}]) - self.broadcast(['get_cur_profile', {'cur_profile': self.GetCurrentProfile()}]) + broadcast(['get_profiles', {'profiles': self.GetProfileNames()}]) + broadcast(['thresholds', {'thresholds': self.GetCurThresholds()}]) + broadcast(['get_cur_profile', {'cur_profile': self.GetCurrentProfile()}]) print('Removed profile "{}". Current thresholds are: {}'.format( profile_name, str(self.GetCurThresholds()))) @@ -160,7 +158,7 @@ def run_fake_serial(write_queue, broadcast, profile_handler): str(profile_handler.GetCurThresholds())) -def run_serial(port, timeout, write_queue, broadcast, profile_handler): +def run_serial(port, timeout, write_queue, profile_handler): """ A function to handle all the serial interactions. Run in a separate thread. @@ -169,7 +167,6 @@ def run_serial(port, timeout, write_queue, broadcast, profile_handler): timeout: int, the time in seconds indicating the timeout for serial operations. write_queue: Queue, a queue object read by the writer thread - broadcast: function to send commands profile_handler: ProfileHandler, the global profile_handler used to update the thresholds """ @@ -281,6 +278,14 @@ async def get_defaults(request): 'thresholds': profile_handler.GetCurThresholds() }) +def broadcast(msg): + with out_queues_lock: + for q in out_queues: + try: + main_thread_loop.call_soon_threadsafe(q.put_nowait, msg) + except asyncio.queues.QueueFull: + pass + async def get_ws(request): ws = web.WebSocketResponse() await ws.prepare(request) @@ -383,22 +388,14 @@ async def on_shutdown(app): out_queues_lock = threading.Lock() main_thread_loop = asyncio.get_event_loop() - def broadcastXX(msg): - with out_queues_lock: - for q in out_queues: - try: - main_thread_loop.call_soon_threadsafe(q.put_nowait, msg) - except asyncio.queues.QueueFull: - pass - - profile_handler = ProfileHandler(broadcastXX) + profile_handler = ProfileHandler() profile_handler.Load() write_queue = queue.Queue(10) if NO_SERIAL: - serial_thread = threading.Thread(target=run_fake_serial, kwargs={'write_queue': write_queue, 'broadcast': broadcastXX, 'profile_handler': profile_handler}) + serial_thread = threading.Thread(target=run_fake_serial, kwargs={'write_queue': write_queue, 'profile_handler': profile_handler}) else: - serial_thread = threading.Thread(target=run_serial, kwargs={'port': SERIAL_PORT, 'timeout': 0.05, 'write_queue': write_queue, 'broadcast': broadcastXX, 'profile_handler': profile_handler}) + serial_thread = threading.Thread(target=run_serial, kwargs={'port': SERIAL_PORT, 'timeout': 0.05, 'write_queue': write_queue, 'profile_handler': profile_handler}) serial_thread.start() hostname = socket.gethostname() From 3737649681ba40d9ce48af7bde8883ede8b0f6f3 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Fri, 4 Feb 2022 23:38:19 -0500 Subject: [PATCH 05/56] Move some server.py setup to a main function --- webui/server/server.py | 252 +++++++++++++++++++++-------------------- 1 file changed, 131 insertions(+), 121 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 8454ddd..976e596 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -19,7 +19,7 @@ SERIAL_PORT = "/dev/ttyACM0" HTTP_PORT = 5000 -# Event to tell the reader and writer threads to exit. +# Event to tell the serial thread to exit. thread_stop_event = threading.Event() # Amount of panels. @@ -32,6 +32,15 @@ # emulate the serial device instead of actually connecting to one. NO_SERIAL = False +# One asyncio queue per open websocket, for broadcasting messages to all clients +out_queues = set() + +# Used to coordinate updates to out_queues. +out_queues_lock = threading.Lock() + +# Allow serial thread to schedule coroutines to run on the main thread. +main_thread_loop = asyncio.get_event_loop() + class ProfileHandler(object): """ @@ -239,44 +248,14 @@ def ProcessThresholds(values): except serial.SerialException as e: logger.error('Error reading data: ', e) - -def update_threshold(values, index): - try: - # Let the writer thread handle updating thresholds. - threshold_cmd = str(sensor_numbers[index]) + str(values[index]) + '\n' - write_queue.put(threshold_cmd, block=False) - except queue.Full: - logger.error('Could not update thresholds. Queue full.') - - -def add_profile(profile_name, thresholds): - profile_handler.AddProfile(profile_name, thresholds) - # When we add a profile, we are using the currently loaded thresholds so we - # don't need to explicitly apply anything. - - -def remove_profile(profile_name): - profile_handler.RemoveProfile(profile_name) - # Need to apply the thresholds of the profile we've fallen back to. - thresholds = profile_handler.GetCurThresholds() - for i in range(len(thresholds)): - update_threshold(thresholds, i) - - -def change_profile(profile_name): - profile_handler.ChangeProfile(profile_name) - # Need to apply the thresholds of the profile we've changed to. - thresholds = profile_handler.GetCurThresholds() - for i in range(len(thresholds)): - update_threshold(thresholds, i) - - -async def get_defaults(request): - return json_response({ - 'profiles': profile_handler.GetProfileNames(), - 'cur_profile': profile_handler.GetCurrentProfile(), - 'thresholds': profile_handler.GetCurThresholds() - }) +def make_get_defaults(profile_handler): + async def get_defaults(request): + return json_response({ + 'profiles': profile_handler.GetProfileNames(), + 'cur_profile': profile_handler.GetCurrentProfile(), + 'thresholds': profile_handler.GetCurThresholds() + }) + return get_defaults def broadcast(msg): with out_queues_lock: @@ -286,72 +265,104 @@ def broadcast(msg): except asyncio.queues.QueueFull: pass -async def get_ws(request): - ws = web.WebSocketResponse() - await ws.prepare(request) +def make_get_ws(profile_handler, write_queue): + def update_threshold(values, index): + try: + # Let the writer thread handle updating thresholds. + threshold_cmd = str(sensor_numbers[index]) + str(values[index]) + '\n' + write_queue.put(threshold_cmd, block=False) + except queue.Full: + logger.error('Could not update thresholds. Queue full.') + - request.app['websockets'].append(ws) - print('Client connected') + def add_profile(profile_name, thresholds): + profile_handler.AddProfile(profile_name, thresholds) + # When we add a profile, we are using the currently loaded thresholds so we + # don't need to explicitly apply anything. - # # Potentially fetch any threshold values from the microcontroller that - # # may be out of sync with our profiles. - # serial_handler.write_queue.put('t\n', block=False) - queue = asyncio.Queue(maxsize=100) - with out_queues_lock: - out_queues.add(queue) - - try: - queue_task = asyncio.create_task(queue.get()) - receive_task = asyncio.create_task(ws.receive()) - connected = True - - while connected: - done, pending = await asyncio.wait([ - queue_task, - receive_task, - ], return_when=asyncio.FIRST_COMPLETED) - - for task in done: - if task == queue_task: - msg = await queue_task - await ws.send_json(msg) - - queue_task = asyncio.create_task(queue.get()) - elif task == receive_task and not ws.closed: - msg = await receive_task - - if msg.type == WSMsgType.TEXT: - data = msg.json() - action = data[0] - - if action == 'update_threshold': - values, index = data[1:] - update_threshold(values, index) - elif action == 'add_profile': - profile_name, thresholds = data[1:] - add_profile(profile_name, thresholds) - elif action == 'remove_profile': - profile_name, = data[1:] - remove_profile(profile_name) - elif action == 'change_profile': - profile_name, = data[1:] - change_profile(profile_name) - elif msg.type == WSMsgType.CLOSE: - connected = False - continue - - receive_task = asyncio.create_task(ws.receive()) - except ConnectionResetError: - pass - finally: - request.app['websockets'].remove(ws) + def remove_profile(profile_name): + profile_handler.RemoveProfile(profile_name) + # Need to apply the thresholds of the profile we've fallen back to. + thresholds = profile_handler.GetCurThresholds() + for i in range(len(thresholds)): + update_threshold(thresholds, i) + + + def change_profile(profile_name): + profile_handler.ChangeProfile(profile_name) + # Need to apply the thresholds of the profile we've changed to. + thresholds = profile_handler.GetCurThresholds() + for i in range(len(thresholds)): + update_threshold(thresholds, i) + + async def get_ws(request): + ws = web.WebSocketResponse() + await ws.prepare(request) + + request.app['websockets'].append(ws) + print('Client connected') + + # # Potentially fetch any threshold values from the microcontroller that + # # may be out of sync with our profiles. + # serial_handler.write_queue.put('t\n', block=False) + + queue = asyncio.Queue(maxsize=100) with out_queues_lock: - out_queues.remove(queue) - queue_task.cancel() - receive_task.cancel() - print('Client disconnected') + out_queues.add(queue) + try: + queue_task = asyncio.create_task(queue.get()) + receive_task = asyncio.create_task(ws.receive()) + connected = True + + while connected: + done, pending = await asyncio.wait([ + queue_task, + receive_task, + ], return_when=asyncio.FIRST_COMPLETED) + + for task in done: + if task == queue_task: + msg = await queue_task + await ws.send_json(msg) + + queue_task = asyncio.create_task(queue.get()) + elif task == receive_task and not ws.closed: + msg = await receive_task + + if msg.type == WSMsgType.TEXT: + data = msg.json() + action = data[0] + + if action == 'update_threshold': + values, index = data[1:] + update_threshold(values, index) + elif action == 'add_profile': + profile_name, thresholds = data[1:] + add_profile(profile_name, thresholds) + elif action == 'remove_profile': + profile_name, = data[1:] + remove_profile(profile_name) + elif action == 'change_profile': + profile_name, = data[1:] + change_profile(profile_name) + elif msg.type == WSMsgType.CLOSE: + connected = False + continue + + receive_task = asyncio.create_task(ws.receive()) + except ConnectionResetError: + pass + finally: + request.app['websockets'].remove(ws) + with out_queues_lock: + out_queues.remove(queue) + queue_task.cancel() + receive_task.cancel() + print('Client disconnected') + + return get_ws build_dir = os.path.abspath( os.path.join(os.path.dirname(__file__), '..', 'build') @@ -366,31 +377,27 @@ async def on_shutdown(app): await ws.close(code=WSCloseCode.GOING_AWAY, message='Server shutdown') thread_stop_event.set() -app = web.Application() +def main(): + profile_handler = ProfileHandler() + profile_handler.Load() + write_queue = queue.Queue(10) + + app = web.Application() -# List of open websockets, to close when the app shuts down. -app['websockets'] = [] + # List of open websockets, to close when the app shuts down. + app['websockets'] = [] -app.add_routes([ - web.get('/defaults', get_defaults), - web.get('/ws', get_ws), -]) -if not NO_SERIAL: app.add_routes([ - web.get('/', get_index), - web.get('/plot', get_index), - web.static('/', build_dir), + web.get('/defaults', make_get_defaults(profile_handler)), + web.get('/ws', make_get_ws(profile_handler, write_queue)), ]) -app.on_shutdown.append(on_shutdown) - -if __name__ == '__main__': - out_queues = set() - out_queues_lock = threading.Lock() - main_thread_loop = asyncio.get_event_loop() - - profile_handler = ProfileHandler() - profile_handler.Load() - write_queue = queue.Queue(10) + if not NO_SERIAL: + app.add_routes([ + web.get('/', get_index), + web.get('/plot', get_index), + web.static('/', build_dir), + ]) + app.on_shutdown.append(on_shutdown) if NO_SERIAL: serial_thread = threading.Thread(target=run_fake_serial, kwargs={'write_queue': write_queue, 'profile_handler': profile_handler}) @@ -403,3 +410,6 @@ async def on_shutdown(app): print(' * WebUI can be found at: http://' + ip_address + ':' + str(HTTP_PORT)) web.run_app(app, port=HTTP_PORT) + +if __name__ == '__main__': + main() From d92faca8a4b1218e08018711b8aba83327836c10 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sat, 5 Feb 2022 00:01:40 -0500 Subject: [PATCH 06/56] Use asyncio.Lock instead of threading.Lock --- webui/server/server.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 976e596..533b076 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -36,7 +36,7 @@ out_queues = set() # Used to coordinate updates to out_queues. -out_queues_lock = threading.Lock() +out_queues_lock = asyncio.Lock() # Allow serial thread to schedule coroutines to run on the main thread. main_thread_loop = asyncio.get_event_loop() @@ -258,12 +258,15 @@ async def get_defaults(request): return get_defaults def broadcast(msg): - with out_queues_lock: - for q in out_queues: - try: - main_thread_loop.call_soon_threadsafe(q.put_nowait, msg) - except asyncio.queues.QueueFull: - pass + async def put_all(): + async with out_queues_lock: + for q in out_queues: + try: + q.put_nowait(msg) + except asyncio.queues.QueueFull: + print('queue full, dropping message' + msg) + pass + asyncio.run_coroutine_threadsafe(put_all(), main_thread_loop) def make_get_ws(profile_handler, write_queue): def update_threshold(values, index): @@ -308,7 +311,7 @@ async def get_ws(request): # serial_handler.write_queue.put('t\n', block=False) queue = asyncio.Queue(maxsize=100) - with out_queues_lock: + async with out_queues_lock: out_queues.add(queue) try: @@ -356,11 +359,11 @@ async def get_ws(request): pass finally: request.app['websockets'].remove(ws) - with out_queues_lock: + async with out_queues_lock: out_queues.remove(queue) - queue_task.cancel() - receive_task.cancel() - print('Client disconnected') + queue_task.cancel() + receive_task.cancel() + print('Client disconnected') return get_ws From 9588ec02ff680214afe1703e68e001396b56422f Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sat, 5 Feb 2022 00:54:11 -0500 Subject: [PATCH 07/56] Add backpressure to broadcasts from serial thread If reading values faster than websockets can send them, wait for websockets to send --- webui/server/server.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 533b076..0c412fa 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -1,5 +1,6 @@ #!/usr/bin/env python import asyncio +import concurrent.futures import logging import os import queue @@ -260,13 +261,21 @@ async def get_defaults(request): def broadcast(msg): async def put_all(): async with out_queues_lock: + for q in out_queues: + await q.put(msg) for q in out_queues: try: - q.put_nowait(msg) - except asyncio.queues.QueueFull: - print('queue full, dropping message' + msg) - pass - asyncio.run_coroutine_threadsafe(put_all(), main_thread_loop) + await asyncio.wait_for(q.join(), timeout=0.1) + except asyncio.TimeoutError: + print('asyncio queue join timeout') + fut = asyncio.run_coroutine_threadsafe(put_all(), main_thread_loop) + if threading.current_thread().name == 'serial': + # If serial thread, block and wait for broadcast + try: + fut.result() + except concurrent.futures.CancelledError: + pass + def make_get_ws(profile_handler, write_queue): def update_threshold(values, index): @@ -329,6 +338,7 @@ async def get_ws(request): if task == queue_task: msg = await queue_task await ws.send_json(msg) + queue.task_done() queue_task = asyncio.create_task(queue.get()) elif task == receive_task and not ws.closed: @@ -403,9 +413,9 @@ def main(): app.on_shutdown.append(on_shutdown) if NO_SERIAL: - serial_thread = threading.Thread(target=run_fake_serial, kwargs={'write_queue': write_queue, 'profile_handler': profile_handler}) + serial_thread = threading.Thread(target=run_fake_serial, name='serial', kwargs={'write_queue': write_queue, 'profile_handler': profile_handler}) else: - serial_thread = threading.Thread(target=run_serial, kwargs={'port': SERIAL_PORT, 'timeout': 0.05, 'write_queue': write_queue, 'profile_handler': profile_handler}) + serial_thread = threading.Thread(target=run_serial, name='serial', kwargs={'port': SERIAL_PORT, 'timeout': 0.05, 'write_queue': write_queue, 'profile_handler': profile_handler}) serial_thread.start() hostname = socket.gethostname() From ff6f76784c388df213a5188c7c3d6a59cba11e75 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sat, 5 Feb 2022 23:25:52 -0500 Subject: [PATCH 08/56] Fit more ValueMonitors without breaking layout --- webui/src/App.css | 22 +++++++++++++++++++++ webui/src/App.js | 49 ++++++++++++++++++++++++++++++++--------------- 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/webui/src/App.css b/webui/src/App.css index af023ba..9c505a1 100644 --- a/webui/src/App.css +++ b/webui/src/App.css @@ -24,4 +24,26 @@ .App-link { color: #61dafb; +} + +.ValueMonitor-row { + height: 100%; + overflow: hidden; +} + +.ValueMonitor-col { + display: flex; + flex-direction: column; +} + +.ValueMonitor-canvas { + border: 1px solid white; + touch-action: none; + flex: 1 0 auto; + height: 0; +} + +.ValueMonitor-buttons, +.ValueMonitor-label { + flex: 0 0 auto; } \ No newline at end of file diff --git a/webui/src/App.js b/webui/src/App.js index c24c354..53a5b0b 100644 --- a/webui/src/App.js +++ b/webui/src/App.js @@ -362,32 +362,47 @@ function ValueMonitor(props) { }, [EmitValue, curThresholds, curValues, index, webUIDataRef]); return( - - - - -
- 0 -
- 0 + +
+ + + +
+ 0 + 0 + /> ); } function ValueMonitors(props) { - const { emit, numSensors, webUIDataRef} = props; + const { numSensors } = props; return (
- - {[...Array(numSensors).keys()].map(index => ( - ) - )} + + {props.children} +
); } @@ -648,7 +663,11 @@ function FSRWebUI(props) { - + + {[...Array(numSensors).keys()].map(index => ( + ) + )} + From 4f207315d21c519ab6f6fcaec63f873ec70de67b Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Tue, 8 Feb 2022 11:58:17 -0500 Subject: [PATCH 09/56] Replace serial thread with asyncio task --- webui/server/server.py | 77 ++++++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 0c412fa..2db733d 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -1,12 +1,9 @@ #!/usr/bin/env python import asyncio -import concurrent.futures import logging import os -import queue import socket import threading -import time from collections import OrderedDict from random import normalvariate @@ -140,7 +137,7 @@ def RemoveProfile(self, profile_name): def GetCurrentProfile(self): return self.cur_profile -def run_fake_serial(write_queue, broadcast, profile_handler): +async def run_fake_serial(write_queue, profile_handler): # Use this to store the values when emulating serial so the graph isn't too # jumpy. Only used when NO_SERIAL is true. no_serial_values = [0] * num_panels @@ -149,8 +146,8 @@ def run_fake_serial(write_queue, broadcast, profile_handler): # Check for command from write_queue try: # The timeout here controls the frequency of checking sensor values. - command = write_queue.get(timeout=0.01) - except queue.Empty: + command = await asyncio.wait_for(write_queue.get(), timeout=0.01) + except asyncio.TimeoutError: # If there is no other pending command, check sensor values. command = 'v\n' if command == 'v\n': @@ -168,15 +165,15 @@ def run_fake_serial(write_queue, broadcast, profile_handler): str(profile_handler.GetCurThresholds())) -def run_serial(port, timeout, write_queue, profile_handler): +async def run_serial(port, timeout, write_queue, profile_handler): """ - A function to handle all the serial interactions. Run in a separate thread. + A function to handle all the serial interactions. Run in a separate Task. Parameters: port: string, the path/name of the serial object to open. timeout: int, the time in seconds indicating the timeout for serial operations. - write_queue: Queue, a queue object read by the writer thread + write_queue: asyncio queue of serial writes profile_handler: ProfileHandler, the global profile_handler used to update the thresholds """ @@ -203,22 +200,26 @@ def ProcessThresholds(values): # Try to open the serial port if needed if not ser: try: - ser = serial.Serial(port, 115200, timeout=timeout) + def open_serial(): + return serial.Serial(port, 115200, timeout=timeout) + ser = await asyncio.to_thread(open_serial) except serial.SerialException as e: ser = None logger.exception('Error opening serial: %s', e) # Delay and retry - time.sleep(1) + await asyncio.sleep(1) continue # Check for command from write_queue try: # The timeout here controls the frequency of checking sensor values. - command = write_queue.get(timeout=0.01) - except queue.Empty: + command = await asyncio.wait_for(write_queue.get(), timeout=0.01) + except asyncio.TimeoutError: # If there is no other pending command, check sensor values. command = 'v\n' try: - ser.write(command.encode()) + def write_command(): + ser.write(command.encode()) + await asyncio.to_thread(write_command) except serial.SerialException as e: logger.error('Error writing data: ', e) # Maybe we need to surface the error higher up? @@ -226,7 +227,9 @@ def ProcessThresholds(values): try: # Wait for a response. # This will block the thread until it gets a newline or until the serial timeout. - line = ser.readline().decode('ascii') + def read_line(): + return ser.readline().decode('ascii') + line = await asyncio.to_thread(read_line) if not line.endswith("\n"): logger.error('Timeout reading response to command.', command, line) @@ -263,18 +266,19 @@ async def put_all(): async with out_queues_lock: for q in out_queues: await q.put(msg) - for q in out_queues: - try: - await asyncio.wait_for(q.join(), timeout=0.1) - except asyncio.TimeoutError: - print('asyncio queue join timeout') - fut = asyncio.run_coroutine_threadsafe(put_all(), main_thread_loop) - if threading.current_thread().name == 'serial': - # If serial thread, block and wait for broadcast - try: - fut.result() - except concurrent.futures.CancelledError: - pass + # for q in out_queues: + # try: + # await asyncio.wait_for(q.join(), timeout=0.1) + # except asyncio.TimeoutError: + # print('asyncio queue join timeout') + asyncio.create_task(put_all()) + # fut = asyncio.run_coroutine_threadsafe(put_all(), main_thread_loop) + # if threading.current_thread().name == 'serial': + # # If serial thread, block and wait for broadcast + # try: + # fut.result() + # except concurrent.futures.CancelledError: + # pass def make_get_ws(profile_handler, write_queue): @@ -282,8 +286,8 @@ def update_threshold(values, index): try: # Let the writer thread handle updating thresholds. threshold_cmd = str(sensor_numbers[index]) + str(values[index]) + '\n' - write_queue.put(threshold_cmd, block=False) - except queue.Full: + write_queue.put_nowait(threshold_cmd) + except asyncio.QueueFull: logger.error('Could not update thresholds. Queue full.') @@ -393,7 +397,13 @@ async def on_shutdown(app): def main(): profile_handler = ProfileHandler() profile_handler.Load() - write_queue = queue.Queue(10) + write_queue = asyncio.Queue(10) + + async def on_startup(app): + if NO_SERIAL: + asyncio.create_task(run_fake_serial(write_queue=write_queue, profile_handler=profile_handler)) + else: + asyncio.create_task(run_serial(port=SERIAL_PORT, timeout=0.05, write_queue=write_queue, profile_handler=profile_handler)) app = web.Application() @@ -411,12 +421,7 @@ def main(): web.static('/', build_dir), ]) app.on_shutdown.append(on_shutdown) - - if NO_SERIAL: - serial_thread = threading.Thread(target=run_fake_serial, name='serial', kwargs={'write_queue': write_queue, 'profile_handler': profile_handler}) - else: - serial_thread = threading.Thread(target=run_serial, name='serial', kwargs={'port': SERIAL_PORT, 'timeout': 0.05, 'write_queue': write_queue, 'profile_handler': profile_handler}) - serial_thread.start() + app.on_startup.append(on_startup) hostname = socket.gethostname() ip_address = socket.gethostbyname(hostname) From 1991cf73791b63b34c7f94e49ec689804cbb8a3a Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Tue, 8 Feb 2022 16:21:13 -0500 Subject: [PATCH 10/56] Don't send ChangeProfile "X" when clicking delete --- webui/src/App.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/webui/src/App.js b/webui/src/App.js index 53a5b0b..f3a811e 100644 --- a/webui/src/App.js +++ b/webui/src/App.js @@ -610,6 +610,9 @@ function FSRWebUI(props) { } function RemoveProfile(e) { + // The X button is inside the Change Profile button, so stop the event from bubbling up to it. + // Another fix would be changing the profile menu layout. + e.stopPropagation(); // Strip out the "X " added by the button. const profile_name = e.target.parentNode.innerText.replace('X ', ''); emit(['remove_profile', profile_name]); From a543fd21e6631823472614b8411fba39cea15981 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Tue, 8 Feb 2022 18:56:16 -0500 Subject: [PATCH 11/56] Refactor around run_websockets task --- webui/server/server.py | 328 +++++++++++++++++++---------------------- 1 file changed, 153 insertions(+), 175 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 2db733d..be96be3 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -3,12 +3,13 @@ import logging import os import socket +import sys import threading from collections import OrderedDict from random import normalvariate import serial -from aiohttp import web, WSCloseCode, WSMsgType +from aiohttp import web, WSMsgType from aiohttp.web import json_response logger = logging.getLogger(__name__) @@ -30,15 +31,14 @@ # emulate the serial device instead of actually connecting to one. NO_SERIAL = False -# One asyncio queue per open websocket, for broadcasting messages to all clients -out_queues = set() +# Queue for broadcasting sensor values +out_queue = asyncio.Queue(maxsize=1) -# Used to coordinate updates to out_queues. -out_queues_lock = asyncio.Lock() - -# Allow serial thread to schedule coroutines to run on the main thread. -main_thread_loop = asyncio.get_event_loop() +# Queue for +receive_queue = asyncio.Queue(maxsize=1) +# Used to coordinate updates to app['websockets'] set +websockets_lock = asyncio.Lock() class ProfileHandler(object): """ @@ -58,6 +58,12 @@ def __init__(self, filename='profiles.txt'): # Have a default no-name profile we can use in case there are no profiles. self.profiles[''] = [0] * num_panels + def __PersistProfiles(self): + with open(self.filename, 'w') as f: + for name, thresholds in self.profiles.items(): + if name: + f.write(name + ' ' + ' '.join(map(str, thresholds)) + '\n') + def Load(self): num_profiles = 0 if os.path.exists(self.filename): @@ -68,7 +74,6 @@ def Load(self): self.profiles[parts[0]] = [int(x) for x in parts[1:]] num_profiles += 1 # Change to the first profile found. - # This will also emit the thresholds. if num_profiles == 1: self.ChangeProfile(parts[0]) else: @@ -76,31 +81,21 @@ def Load(self): print('Found Profiles: ' + str(list(self.profiles.keys()))) def GetCurThresholds(self): - if self.cur_profile in self.profiles: - return self.profiles[self.cur_profile] - else: - # Should never get here assuming cur_profile is always appropriately - # updated, but you never know. - self.ChangeProfile('') - return self.profiles[self.cur_profile] + if not self.cur_profile in self.profiles: + raise RuntimeError("Current profile name is missing from profile list") + return self.profiles[self.cur_profile] def UpdateThresholds(self, index, value): - if self.cur_profile in self.profiles: - self.profiles[self.cur_profile][index] = value - with open(self.filename, 'w') as f: - for name, thresholds in self.profiles.items(): - if name: - f.write(name + ' ' + ' '.join(map(str, thresholds)) + '\n') - broadcast(['thresholds', {'thresholds': self.GetCurThresholds()}]) - print('Thresholds are: ' + str(self.GetCurThresholds())) + if not self.cur_profile in self.profiles: + raise RuntimeError("Current profile name is missing from profile list") + self.profiles[self.cur_profile][index] = value + self.__PersistProfiles() def ChangeProfile(self, profile_name): - if profile_name in self.profiles: - self.cur_profile = profile_name - broadcast(['thresholds', {'thresholds': self.GetCurThresholds()}]) - broadcast(['get_cur_profile', {'cur_profile': self.GetCurrentProfile()}]) - print('Changed to profile "{}" with thresholds: {}'.format( - self.GetCurrentProfile(), str(self.GetCurThresholds()))) + if not profile_name in self.profiles: + print(profile_name, " not in ", self.profiles) + raise RuntimeError("Selected profile name is missing from profile list") + self.cur_profile = profile_name def GetProfileNames(self): return [name for name in self.profiles.keys() if name] @@ -109,30 +104,17 @@ def AddProfile(self, profile_name, thresholds): self.profiles[profile_name] = thresholds if self.cur_profile == '': self.profiles[''] = [0] * num_panels - # ChangeProfile emits 'thresholds' and 'cur_profile' self.ChangeProfile(profile_name) - with open(self.filename, 'w') as f: - for name, thresholds in self.profiles.items(): - if name: - f.write(name + ' ' + ' '.join(map(str, thresholds)) + '\n') - broadcast(['get_profiles', {'profiles': self.GetProfileNames()}]) - print('Added profile "{}" with thresholds: {}'.format( - self.GetCurrentProfile(), str(self.GetCurThresholds()))) + self.__PersistProfiles() def RemoveProfile(self, profile_name): - if profile_name in self.profiles: - del self.profiles[profile_name] - if profile_name == self.cur_profile: - self.ChangeProfile('') - with open(self.filename, 'w') as f: - for name, thresholds in self.profiles.items(): - if name: - f.write(name + ' ' + ' '.join(map(str, thresholds)) + '\n') - broadcast(['get_profiles', {'profiles': self.GetProfileNames()}]) - broadcast(['thresholds', {'thresholds': self.GetCurThresholds()}]) - broadcast(['get_cur_profile', {'cur_profile': self.GetCurrentProfile()}]) - print('Removed profile "{}". Current thresholds are: {}'.format( - profile_name, str(self.GetCurThresholds()))) + if not profile_name in self.profiles: + print(profile_name, " not in ", self.profiles) + raise RuntimeError("Selected profile name is missing from profile list") + del self.profiles[profile_name] + if profile_name == self.cur_profile: + self.ChangeProfile('') + self.__PersistProfiles() def GetCurrentProfile(self): return self.cur_profile @@ -156,13 +138,14 @@ async def run_fake_serial(write_queue, profile_handler): max(0, min(no_serial_values[i] + offsets[i], 1023)) for i in range(num_panels) ] - broadcast(['values', {'values': no_serial_values}]) - elif command == 't\n': - if command[0] == 't': - broadcast(['thresholds', - {'thresholds': profile_handler.GetCurThresholds()}]) - print('Thresholds are: ' + - str(profile_handler.GetCurThresholds())) + # broadcast(['values', {'values': no_serial_values}]) + out_queue.put_nowait(['values', {'values': no_serial_values}]) + # elif command == 't\n': + # if command[0] == 't': + # broadcast(['thresholds', + # {'thresholds': profile_handler.GetCurThresholds()}]) + # print('Thresholds are: ' + + # str(profile_handler.GetCurThresholds())) async def run_serial(port, timeout, write_queue, profile_handler): @@ -179,12 +162,15 @@ async def run_serial(port, timeout, write_queue, profile_handler): """ ser = None - def ProcessValues(values): + async def ProcessValues(values): # Fix our sensor ordering. actual = [] for i in range(num_panels): actual.append(values[sensor_numbers[i]]) - broadcast(['values', {'values': actual}]) + try: + out_queue.put_nowait(['values', {'values': actual}]) + except asyncio.QueueFull: + print('queue full') def ProcessThresholds(values): cur_thresholds = profile_handler.GetCurThresholds() @@ -246,152 +232,140 @@ def read_line(): values = [int(x) for x in parts[1:]] if cmd == 'v': - ProcessValues(values) - elif cmd == 't': - ProcessThresholds(values) + await ProcessValues(values) + # elif cmd == 't': + # ProcessThresholds(values) except serial.SerialException as e: logger.error('Error reading data: ', e) -def make_get_defaults(profile_handler): - async def get_defaults(request): - return json_response({ - 'profiles': profile_handler.GetProfileNames(), - 'cur_profile': profile_handler.GetCurrentProfile(), - 'thresholds': profile_handler.GetCurThresholds() - }) - return get_defaults +async def run_websockets(app, write_queue, profile_handler): + async def send_json_all(msg): + websockets = app['websockets'].copy() + for ws in websockets: + if not ws.closed: + await ws.send_json(msg) -def broadcast(msg): - async def put_all(): - async with out_queues_lock: - for q in out_queues: - await q.put(msg) - # for q in out_queues: - # try: - # await asyncio.wait_for(q.join(), timeout=0.1) - # except asyncio.TimeoutError: - # print('asyncio queue join timeout') - asyncio.create_task(put_all()) - # fut = asyncio.run_coroutine_threadsafe(put_all(), main_thread_loop) - # if threading.current_thread().name == 'serial': - # # If serial thread, block and wait for broadcast - # try: - # fut.result() - # except concurrent.futures.CancelledError: - # pass - - -def make_get_ws(profile_handler, write_queue): - def update_threshold(values, index): + async def update_threshold(values, index): + profile_handler.UpdateThresholds(index, values[index]) try: - # Let the writer thread handle updating thresholds. threshold_cmd = str(sensor_numbers[index]) + str(values[index]) + '\n' - write_queue.put_nowait(threshold_cmd) - except asyncio.QueueFull: + await asyncio.wait_for(write_queue.put(threshold_cmd), timeout=0.1) + except asyncio.TimeoutError: logger.error('Could not update thresholds. Queue full.') + await send_json_all(['thresholds', {'thresholds': profile_handler.GetCurThresholds()}]) + print('Thresholds are: ' + str(profile_handler.GetCurThresholds())) - - def add_profile(profile_name, thresholds): + async def add_profile(profile_name, thresholds): profile_handler.AddProfile(profile_name, thresholds) # When we add a profile, we are using the currently loaded thresholds so we # don't need to explicitly apply anything. + await send_json_all(['get_profiles', {'profiles': profile_handler.GetProfileNames()}]) + print('Added profile "{}" with thresholds: {}'.format( + profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) + await send_json_all(['get_cur_profile', {'cur_profile': profile_handler.GetCurrentProfile()}]) + print('Changed to profile "{}" with thresholds: {}'.format( + profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) - - def remove_profile(profile_name): + async def remove_profile(profile_name): profile_handler.RemoveProfile(profile_name) # Need to apply the thresholds of the profile we've fallen back to. thresholds = profile_handler.GetCurThresholds() for i in range(len(thresholds)): - update_threshold(thresholds, i) + await update_threshold(thresholds, i) + await send_json_all(['get_profiles', {'profiles': profile_handler.GetProfileNames()}]) + await send_json_all(['get_cur_profile', {'cur_profile': profile_handler.GetCurrentProfile()}]) + print('Removed profile "{}". Current thresholds are: {}'.format( + profile_name, str(profile_handler.GetCurThresholds()))) - - def change_profile(profile_name): + async def change_profile(profile_name): profile_handler.ChangeProfile(profile_name) # Need to apply the thresholds of the profile we've changed to. thresholds = profile_handler.GetCurThresholds() for i in range(len(thresholds)): - update_threshold(thresholds, i) - - async def get_ws(request): - ws = web.WebSocketResponse() - await ws.prepare(request) - - request.app['websockets'].append(ws) - print('Client connected') + await update_threshold(thresholds, i) + await send_json_all(['get_cur_profile', {'cur_profile': profile_handler.GetCurrentProfile()}]) + print('Changed to profile "{}" with thresholds: {}'.format( + profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) + + try: + out_queue_task = asyncio.create_task(out_queue.get()) + receive_queue_task = asyncio.create_task(receive_queue.get()) + while True: + done, pending = await asyncio.wait([out_queue_task, receive_queue_task], return_when=asyncio.FIRST_COMPLETED) + + for task in done: + if task == out_queue_task: + msg = await task + await send_json_all(msg) + out_queue.task_done() + out_queue_task = asyncio.create_task(out_queue.get()) + if task == receive_queue_task: + data = await task + + action = data[0] + + if action == 'update_threshold': + values, index = data[1:] + await update_threshold(values, index) + elif action == 'add_profile': + profile_name, thresholds = data[1:] + await add_profile(profile_name, thresholds) + elif action == 'remove_profile': + profile_name, = data[1:] + await remove_profile(profile_name) + elif action == 'change_profile': + profile_name, = data[1:] + await change_profile(profile_name) + receive_queue.task_done() + receive_queue_task = asyncio.create_task(receive_queue.get()) + except RuntimeError: + sys.exit(1) - # # Potentially fetch any threshold values from the microcontroller that - # # may be out of sync with our profiles. - # serial_handler.write_queue.put('t\n', block=False) +def make_get_defaults(profile_handler): + async def get_defaults(request): + return json_response({ + 'profiles': profile_handler.GetProfileNames(), + 'cur_profile': profile_handler.GetCurrentProfile(), + 'thresholds': profile_handler.GetCurThresholds() + }) + return get_defaults - queue = asyncio.Queue(maxsize=100) - async with out_queues_lock: - out_queues.add(queue) +async def get_ws(request): + this_task = asyncio.current_task() + ws = web.WebSocketResponse() + await ws.prepare(request) - try: - queue_task = asyncio.create_task(queue.get()) - receive_task = asyncio.create_task(ws.receive()) - connected = True - - while connected: - done, pending = await asyncio.wait([ - queue_task, - receive_task, - ], return_when=asyncio.FIRST_COMPLETED) - - for task in done: - if task == queue_task: - msg = await queue_task - await ws.send_json(msg) - queue.task_done() - - queue_task = asyncio.create_task(queue.get()) - elif task == receive_task and not ws.closed: - msg = await receive_task - - if msg.type == WSMsgType.TEXT: - data = msg.json() - action = data[0] - - if action == 'update_threshold': - values, index = data[1:] - update_threshold(values, index) - elif action == 'add_profile': - profile_name, thresholds = data[1:] - add_profile(profile_name, thresholds) - elif action == 'remove_profile': - profile_name, = data[1:] - remove_profile(profile_name) - elif action == 'change_profile': - profile_name, = data[1:] - change_profile(profile_name) - elif msg.type == WSMsgType.CLOSE: - connected = False - continue - - receive_task = asyncio.create_task(ws.receive()) - except ConnectionResetError: - pass - finally: - request.app['websockets'].remove(ws) - async with out_queues_lock: - out_queues.remove(queue) - queue_task.cancel() - receive_task.cancel() - print('Client disconnected') + async with websockets_lock: + request.app['websockets'].add(ws) + request.app['websocket-tasks'].add(this_task) + print('Client connected') - return get_ws + try: + while not ws.closed: + msg = await ws.receive() + if msg.type == WSMsgType.CLOSE: + break + elif msg.type == WSMsgType.TEXT: + data = msg.json() + print("putting", data) + await receive_queue.put(data) + finally: + async with websockets_lock: + request.app['websockets'].remove(ws) + request.app['websocket-tasks'].remove(this_task) + print('Client disconnected') build_dir = os.path.abspath( os.path.join(os.path.dirname(__file__), '..', 'build') ) - async def get_index(request): return web.FileResponse(os.path.join(build_dir, 'index.html')) async def on_shutdown(app): - for ws in app['websockets']: - await ws.close(code=WSCloseCode.GOING_AWAY, message='Server shutdown') + async with websockets_lock: + for task in app['websocket-tasks']: + task.cancel() thread_stop_event.set() def main(): @@ -405,14 +379,18 @@ async def on_startup(app): else: asyncio.create_task(run_serial(port=SERIAL_PORT, timeout=0.05, write_queue=write_queue, profile_handler=profile_handler)) + asyncio.create_task(run_websockets(app=app, write_queue=write_queue, profile_handler=profile_handler)) + app = web.Application() - # List of open websockets, to close when the app shuts down. - app['websockets'] = [] + # Set of open websockets used to broadcast messages to all clients. + app['websockets'] = set() + # Set of open websocket tasks to cancel when the app shuts down. + app['websocket-tasks'] = set() app.add_routes([ web.get('/defaults', make_get_defaults(profile_handler)), - web.get('/ws', make_get_ws(profile_handler, write_queue)), + web.get('/ws', get_ws), ]) if not NO_SERIAL: app.add_routes([ From 9dfe01a2f4598861af497d6d1c4e005892eba590 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Tue, 8 Feb 2022 20:10:07 -0500 Subject: [PATCH 12/56] Refactor to move serial handling back to a class Some error handling missing, value monitoring missing --- webui/server/server.py | 191 ++++++++++++++++------------------------- 1 file changed, 76 insertions(+), 115 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index be96be3..f4693e2 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -119,126 +119,85 @@ def RemoveProfile(self, profile_name): def GetCurrentProfile(self): return self.cur_profile -async def run_fake_serial(write_queue, profile_handler): - # Use this to store the values when emulating serial so the graph isn't too - # jumpy. Only used when NO_SERIAL is true. - no_serial_values = [0] * num_panels +class FakeSerialHandler(object): + def __init__(self): + self.__is_open = False + # Use this to store the values when emulating serial so the graph isn't too + # jumpy. Only used when NO_SERIAL is true. + self.__no_serial_values = [0] * num_panels - while not thread_stop_event.is_set(): - # Check for command from write_queue - try: - # The timeout here controls the frequency of checking sensor values. - command = await asyncio.wait_for(write_queue.get(), timeout=0.01) - except asyncio.TimeoutError: - # If there is no other pending command, check sensor values. - command = 'v\n' + def Open(self): + self.__is_open = True + + def Close(self): + self.__is_open = False + + def isOpen(self): + return self.__is_open + + def Send(self, command): if command == 'v\n': offsets = [int(normalvariate(0, num_panels+1)) for _ in range(num_panels)] - no_serial_values = [ - max(0, min(no_serial_values[i] + offsets[i], 1023)) + self.__no_serial_values = [ + max(0, min(self.__no_serial_values[i] + offsets[i], 1023)) for i in range(num_panels) ] - # broadcast(['values', {'values': no_serial_values}]) - out_queue.put_nowait(['values', {'values': no_serial_values}]) - # elif command == 't\n': - # if command[0] == 't': - # broadcast(['thresholds', - # {'thresholds': profile_handler.GetCurThresholds()}]) - # print('Thresholds are: ' + - # str(profile_handler.GetCurThresholds())) + return 'v', self.__no_serial_values.copy() + elif command == 't\n': + return 't', [0] * num_panels +class CommandFormatError(Exception): + pass -async def run_serial(port, timeout, write_queue, profile_handler): +class SerialHandler(object): """ - A function to handle all the serial interactions. Run in a separate Task. + A class to handle all the serial interactions. - Parameters: + Attributes: + ser: Serial, the serial object opened by this class. port: string, the path/name of the serial object to open. timeout: int, the time in seconds indicating the timeout for serial operations. - write_queue: asyncio queue of serial writes - profile_handler: ProfileHandler, the global profile_handler used to update - the thresholds """ - ser = None + def __init__(self, port, timeout=1): + self.ser = None + self.port = port + self.timeout = timeout + + def Open(self): + self.ser = serial.Serial(self.port, 115200, timeout=self.timeout) - async def ProcessValues(values): - # Fix our sensor ordering. - actual = [] - for i in range(num_panels): - actual.append(values[sensor_numbers[i]]) - try: - out_queue.put_nowait(['values', {'values': actual}]) - except asyncio.QueueFull: - print('queue full') - - def ProcessThresholds(values): - cur_thresholds = profile_handler.GetCurThresholds() - # Fix our sensor ordering. - actual = [] - for i in range(num_panels): - actual.append(values[sensor_numbers[i]]) - for i, (cur, act) in enumerate(zip(cur_thresholds, actual)): - if cur != act: - profile_handler.UpdateThresholds(i, act) - - while not thread_stop_event.is_set(): - # Try to open the serial port if needed - if not ser: - try: - def open_serial(): - return serial.Serial(port, 115200, timeout=timeout) - ser = await asyncio.to_thread(open_serial) - except serial.SerialException as e: - ser = None - logger.exception('Error opening serial: %s', e) - # Delay and retry - await asyncio.sleep(1) - continue - # Check for command from write_queue - try: - # The timeout here controls the frequency of checking sensor values. - command = await asyncio.wait_for(write_queue.get(), timeout=0.01) - except asyncio.TimeoutError: - # If there is no other pending command, check sensor values. - command = 'v\n' + def Close(self): try: - def write_command(): - ser.write(command.encode()) - await asyncio.to_thread(write_command) - except serial.SerialException as e: - logger.error('Error writing data: ', e) - # Maybe we need to surface the error higher up? - continue - try: - # Wait for a response. - # This will block the thread until it gets a newline or until the serial timeout. - def read_line(): - return ser.readline().decode('ascii') - line = await asyncio.to_thread(read_line) - - if not line.endswith("\n"): - logger.error('Timeout reading response to command.', command, line) - continue - - line = line.strip() - - # All commands are of the form: - # cmd num1 num2 num3 num4 - parts = line.split() - if len(parts) != num_panels+1: - continue - cmd = parts[0] - values = [int(x) for x in parts[1:]] - - if cmd == 'v': - await ProcessValues(values) - # elif cmd == 't': - # ProcessThresholds(values) - except serial.SerialException as e: - logger.error('Error reading data: ', e) - -async def run_websockets(app, write_queue, profile_handler): + self.ser.close() + except: + pass + self.ser = None + + def isOpen(self): + return self.ser and self.ser.isOpen() + + def Send(self, command): + self.ser.write(command.encode()) + + line = self.ser.readline().decode('ascii') + + if not line.endswith('\n'): + raise TimeoutError('Timeout reading response to command. {} {}'.format(command, line)) + + line = line.strip() + + # All commands are of the form: + # cmd num1 num2 num3 num4 + parts = line.split() + if len(parts) != num_panels + 1: + raise CommandFormatError + cmd = parts[0] + values = [int(x) for x in parts[1:]] + return cmd, values + + +async def run_websockets(app, serial_handler, profile_handler): async def send_json_all(msg): websockets = app['websockets'].copy() for ws in websockets: @@ -249,9 +208,12 @@ async def update_threshold(values, index): profile_handler.UpdateThresholds(index, values[index]) try: threshold_cmd = str(sensor_numbers[index]) + str(values[index]) + '\n' - await asyncio.wait_for(write_queue.put(threshold_cmd), timeout=0.1) - except asyncio.TimeoutError: - logger.error('Could not update thresholds. Queue full.') + if not serial_handler.isOpen(): + await asyncio.to_thread(lambda: serial_handler.Open()) + await asyncio.to_thread(lambda: serial_handler.Send(threshold_cmd)) + except: + print("Serial error") + sys.exit(1) await send_json_all(['thresholds', {'thresholds': profile_handler.GetCurThresholds()}]) print('Thresholds are: ' + str(profile_handler.GetCurThresholds())) @@ -371,15 +333,14 @@ async def on_shutdown(app): def main(): profile_handler = ProfileHandler() profile_handler.Load() - write_queue = asyncio.Queue(10) - async def on_startup(app): - if NO_SERIAL: - asyncio.create_task(run_fake_serial(write_queue=write_queue, profile_handler=profile_handler)) - else: - asyncio.create_task(run_serial(port=SERIAL_PORT, timeout=0.05, write_queue=write_queue, profile_handler=profile_handler)) + if NO_SERIAL: + serial_handler = FakeSerialHandler() + else: + serial_handler = SerialHandler(port=SERIAL_PORT, timeout=0.05) - asyncio.create_task(run_websockets(app=app, write_queue=write_queue, profile_handler=profile_handler)) + async def on_startup(app): + asyncio.create_task(run_websockets(app=app, serial_handler=serial_handler, profile_handler=profile_handler)) app = web.Application() From 8ad2cbdef48bfdf47679a155232f81425e663cb7 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Tue, 8 Feb 2022 20:32:50 -0500 Subject: [PATCH 13/56] Bring back value polling --- webui/server/server.py | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index f4693e2..e16e64a 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -31,10 +31,7 @@ # emulate the serial device instead of actually connecting to one. NO_SERIAL = False -# Queue for broadcasting sensor values -out_queue = asyncio.Queue(maxsize=1) - -# Queue for +# Queue for websocket Tasks to pass messages they receive from clients to the run_websockets task. receive_queue = asyncio.Queue(maxsize=1) # Used to coordinate updates to app['websockets'] set @@ -208,8 +205,6 @@ async def update_threshold(values, index): profile_handler.UpdateThresholds(index, values[index]) try: threshold_cmd = str(sensor_numbers[index]) + str(values[index]) + '\n' - if not serial_handler.isOpen(): - await asyncio.to_thread(lambda: serial_handler.Open()) await asyncio.to_thread(lambda: serial_handler.Send(threshold_cmd)) except: print("Serial error") @@ -249,18 +244,31 @@ async def change_profile(profile_name): print('Changed to profile "{}" with thresholds: {}'.format( profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) + async def get_values(): + try: + return await asyncio.to_thread(lambda: serial_handler.Send('v\n')) + except: + print("Serial error") + sys.exit(1) + + async def report_values(values): + await send_json_all(['values', {'values': values}]) + + await asyncio.to_thread(lambda: serial_handler.Open()) + + poll_values_wait_seconds = 0.01 + try: - out_queue_task = asyncio.create_task(out_queue.get()) + poll_values_task = asyncio.create_task(asyncio.sleep(poll_values_wait_seconds)) receive_queue_task = asyncio.create_task(receive_queue.get()) while True: - done, pending = await asyncio.wait([out_queue_task, receive_queue_task], return_when=asyncio.FIRST_COMPLETED) + done, pending = await asyncio.wait([poll_values_task, receive_queue_task], return_when=asyncio.FIRST_COMPLETED) for task in done: - if task == out_queue_task: - msg = await task - await send_json_all(msg) - out_queue.task_done() - out_queue_task = asyncio.create_task(out_queue.get()) + if task == poll_values_task: + v, values = await get_values() + await report_values(values) + poll_values_task = asyncio.create_task(asyncio.sleep(poll_values_wait_seconds)) if task == receive_queue_task: data = await task @@ -309,7 +317,6 @@ async def get_ws(request): break elif msg.type == WSMsgType.TEXT: data = msg.json() - print("putting", data) await receive_queue.put(data) finally: async with websockets_lock: From 5e8e16778ff56e15cfdf538bc2ca51eccc047c20 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Wed, 9 Feb 2022 00:04:37 -0500 Subject: [PATCH 14/56] Reconnect serial handler after error --- webui/server/server.py | 52 +++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index e16e64a..75caf2a 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -4,7 +4,6 @@ import os import socket import sys -import threading from collections import OrderedDict from random import normalvariate @@ -18,9 +17,6 @@ SERIAL_PORT = "/dev/ttyACM0" HTTP_PORT = 5000 -# Event to tell the serial thread to exit. -thread_stop_event = threading.Event() - # Amount of panels. num_panels = 4 @@ -165,10 +161,8 @@ def Open(self): self.ser = serial.Serial(self.port, 115200, timeout=self.timeout) def Close(self): - try: + if self.ser and not self.ser.closed: self.ser.close() - except: - pass self.ser = None def isOpen(self): @@ -188,7 +182,7 @@ def Send(self, command): # cmd num1 num2 num3 num4 parts = line.split() if len(parts) != num_panels + 1: - raise CommandFormatError + raise CommandFormatError('Command response "{}" had length {}, expected length was {}'.format(line, len(parts), num_panels + 1)) cmd = parts[0] values = [int(x) for x in parts[1:]] return cmd, values @@ -203,12 +197,8 @@ async def send_json_all(msg): async def update_threshold(values, index): profile_handler.UpdateThresholds(index, values[index]) - try: - threshold_cmd = str(sensor_numbers[index]) + str(values[index]) + '\n' - await asyncio.to_thread(lambda: serial_handler.Send(threshold_cmd)) - except: - print("Serial error") - sys.exit(1) + threshold_cmd = str(sensor_numbers[index]) + str(values[index]) + '\n' + await asyncio.to_thread(lambda: serial_handler.Send(threshold_cmd)) await send_json_all(['thresholds', {'thresholds': profile_handler.GetCurThresholds()}]) print('Thresholds are: ' + str(profile_handler.GetCurThresholds())) @@ -247,18 +237,16 @@ async def change_profile(profile_name): async def get_values(): try: return await asyncio.to_thread(lambda: serial_handler.Send('v\n')) - except: - print("Serial error") + except CommandFormatError as e: + logger.exception("Bad response from v command: %s", e) sys.exit(1) async def report_values(values): await send_json_all(['values', {'values': values}]) - await asyncio.to_thread(lambda: serial_handler.Open()) - poll_values_wait_seconds = 0.01 - try: + async def task_loop(): poll_values_task = asyncio.create_task(asyncio.sleep(poll_values_wait_seconds)) receive_queue_task = asyncio.create_task(receive_queue.get()) while True: @@ -266,8 +254,9 @@ async def report_values(values): for task in done: if task == poll_values_task: - v, values = await get_values() - await report_values(values) + if len(app['websockets']) > 0: + v, values = await get_values() + await report_values(values) poll_values_task = asyncio.create_task(asyncio.sleep(poll_values_wait_seconds)) if task == receive_queue_task: data = await task @@ -288,8 +277,23 @@ async def report_values(values): await change_profile(profile_name) receive_queue.task_done() receive_queue_task = asyncio.create_task(receive_queue.get()) - except RuntimeError: - sys.exit(1) + + while True: + try: + await asyncio.to_thread(lambda: serial_handler.Open()) + print("Serial connected") + except serial.SerialException: + print("Couldn't connect to serial. Retrying...") + await asyncio.sleep(1) + continue + try: + await task_loop() + except serial.SerialException as e: + # In case of serial error, disconnect all clients and try to connect again + logger.exception('Serial error: %s', e) + async with websockets_lock: + for task in app['websocket-tasks']: + task.cancel() def make_get_defaults(profile_handler): async def get_defaults(request): @@ -322,6 +326,7 @@ async def get_ws(request): async with websockets_lock: request.app['websockets'].remove(ws) request.app['websocket-tasks'].remove(this_task) + await ws.close() print('Client disconnected') build_dir = os.path.abspath( @@ -335,7 +340,6 @@ async def on_shutdown(app): async with websockets_lock: for task in app['websocket-tasks']: task.cancel() - thread_stop_event.set() def main(): profile_handler = ProfileHandler() From 9b5a8d1b5df98b99ada819521db226ebb8bc26a8 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Wed, 9 Feb 2022 00:44:53 -0500 Subject: [PATCH 15/56] Make WebUI wait for serial connection --- webui/server/server.py | 18 ++++++++++++++---- webui/src/App.js | 8 +++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 75caf2a..d109272 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -27,6 +27,9 @@ # emulate the serial device instead of actually connecting to one. NO_SERIAL = False +# Track whether there is an active serial connection +serial_connected = False + # Queue for websocket Tasks to pass messages they receive from clients to the run_websockets task. receive_queue = asyncio.Queue(maxsize=1) @@ -189,6 +192,7 @@ def Send(self, command): async def run_websockets(app, serial_handler, profile_handler): + global serial_connected async def send_json_all(msg): websockets = app['websockets'].copy() for ws in websockets: @@ -282,21 +286,25 @@ async def task_loop(): try: await asyncio.to_thread(lambda: serial_handler.Open()) print("Serial connected") - except serial.SerialException: - print("Couldn't connect to serial. Retrying...") - await asyncio.sleep(1) + serial_connected = True + except serial.SerialException as e: + logger.exception('Can\'t connect to serial: %s', e) + await asyncio.sleep(3) continue try: await task_loop() except serial.SerialException as e: - # In case of serial error, disconnect all clients and try to connect again + # In case of serial error, disconnect all clients. The WebUI will try to reconnect. logger.exception('Serial error: %s', e) + serial_connected = False async with websockets_lock: for task in app['websocket-tasks']: task.cancel() def make_get_defaults(profile_handler): async def get_defaults(request): + if not serial_connected: + return json_response({}, status=503) return json_response({ 'profiles': profile_handler.GetProfileNames(), 'cur_profile': profile_handler.GetCurrentProfile(), @@ -305,6 +313,8 @@ async def get_defaults(request): return get_defaults async def get_ws(request): + if not serial_connected: + return json_response({}, status=503) this_task = asyncio.current_task() ws = web.WebSocketResponse() await ws.prepare(request) diff --git a/webui/src/App.js b/webui/src/App.js index f3a811e..2e9f8a4 100644 --- a/webui/src/App.js +++ b/webui/src/App.js @@ -38,7 +38,13 @@ function useDefaults() { const getDefaults = () => { clearTimeout(timeoutId); - fetch('/defaults').then(res => res.json()).then(data => { + fetch('/defaults').then(res => { + if (res.status === 200) { + return res.json(); + } else { + throw new Error(); + } + }).then(data => { if (!cleaningUp) { setDefaults(data); } From 8eaa5bd94690c81a571fad7d3c2b8ea71c7f4b54 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Wed, 9 Feb 2022 01:07:54 -0500 Subject: [PATCH 16/56] Send thresholds after serial connect * Verify threshold values after initial save * Save thresholds to fake serial instance --- webui/server/server.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index d109272..70c5e73 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -121,6 +121,7 @@ def __init__(self): # Use this to store the values when emulating serial so the graph isn't too # jumpy. Only used when NO_SERIAL is true. self.__no_serial_values = [0] * num_panels + self.__thresholds = [0] * num_panels def Open(self): self.__is_open = True @@ -140,7 +141,11 @@ def Send(self, command): ] return 'v', self.__no_serial_values.copy() elif command == 't\n': - return 't', [0] * num_panels + return 't', self.__thresholds.copy() + elif "0123456789".find(command[0]) != -1: + sensor_index = int(command[0]) + self.__thresholds[sensor_index] = int(command[1:]) + return 't', self.__thresholds.copy() class CommandFormatError(Exception): pass @@ -285,13 +290,21 @@ async def task_loop(): while True: try: await asyncio.to_thread(lambda: serial_handler.Open()) - print("Serial connected") + print('Serial connected') serial_connected = True except serial.SerialException as e: logger.exception('Can\'t connect to serial: %s', e) await asyncio.sleep(3) continue try: + # Send current thresholds on connect + cur_thresholds = profile_handler.GetCurThresholds() + for i, threshold in enumerate(cur_thresholds): + threshold_cmd = str(i) + str(threshold) + '\n' + t, thresholds = await asyncio.to_thread(lambda: serial_handler.Send(threshold_cmd)) + if not str(cur_thresholds) == str(thresholds): + print('Microcontroller did not save thresholds. Profile: {}, MCU: {}'.format(str(cur_thresholds), str(thresholds))) + sys.exit(1) await task_loop() except serial.SerialException as e: # In case of serial error, disconnect all clients. The WebUI will try to reconnect. From 3674b555b8272475ff16229b0a1ff9d0c8a3e4a0 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Wed, 9 Feb 2022 19:14:59 -0500 Subject: [PATCH 17/56] Remove sensor_numbers range --- webui/server/server.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 70c5e73..4baf8f6 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -20,9 +20,6 @@ # Amount of panels. num_panels = 4 -# Initialize panel ids. -sensor_numbers = range(num_panels) - # Used for developmental purposes. Set this to true when you just want to # emulate the serial device instead of actually connecting to one. NO_SERIAL = False @@ -206,7 +203,7 @@ async def send_json_all(msg): async def update_threshold(values, index): profile_handler.UpdateThresholds(index, values[index]) - threshold_cmd = str(sensor_numbers[index]) + str(values[index]) + '\n' + threshold_cmd = str(index) + str(values[index]) + '\n' await asyncio.to_thread(lambda: serial_handler.Send(threshold_cmd)) await send_json_all(['thresholds', {'thresholds': profile_handler.GetCurThresholds()}]) print('Thresholds are: ' + str(profile_handler.GetCurThresholds())) From cd88fc44d400e3a98fd09d959056ce99644bb557 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Wed, 9 Feb 2022 19:44:27 -0500 Subject: [PATCH 18/56] Construct ProfileHandler in run_websockets --- webui/server/server.py | 53 +++++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 4baf8f6..9997e58 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -193,7 +193,10 @@ def Send(self, command): return cmd, values -async def run_websockets(app, serial_handler, profile_handler): +async def run_websockets(app, serial_handler, get_defaults): + profile_handler = ProfileHandler() + profile_handler.Load() + global serial_connected async def send_json_all(msg): websockets = app['websockets'].copy() @@ -289,11 +292,13 @@ async def task_loop(): await asyncio.to_thread(lambda: serial_handler.Open()) print('Serial connected') serial_connected = True - except serial.SerialException as e: - logger.exception('Can\'t connect to serial: %s', e) - await asyncio.sleep(3) - continue - try: + async def get_defaults_handler(request): + return json_response({ + 'profiles': profile_handler.GetProfileNames(), + 'cur_profile': profile_handler.GetCurrentProfile(), + 'thresholds': profile_handler.GetCurThresholds() + }) + get_defaults.set_handler(get_defaults_handler) # Send current thresholds on connect cur_thresholds = profile_handler.GetCurThresholds() for i, threshold in enumerate(cur_thresholds): @@ -305,22 +310,14 @@ async def task_loop(): await task_loop() except serial.SerialException as e: # In case of serial error, disconnect all clients. The WebUI will try to reconnect. + serial_handler.Close() logger.exception('Serial error: %s', e) serial_connected = False + get_defaults.reset_handler() async with websockets_lock: for task in app['websocket-tasks']: task.cancel() - -def make_get_defaults(profile_handler): - async def get_defaults(request): - if not serial_connected: - return json_response({}, status=503) - return json_response({ - 'profiles': profile_handler.GetProfileNames(), - 'cur_profile': profile_handler.GetCurrentProfile(), - 'thresholds': profile_handler.GetCurThresholds() - }) - return get_defaults + await asyncio.sleep(3) async def get_ws(request): if not serial_connected: @@ -361,9 +358,23 @@ async def on_shutdown(app): for task in app['websocket-tasks']: task.cancel() +class GetDefaults(object): + def __init__(self): + self.reset_handler() + + def reset_handler(self): + async def handler(request): + json_response({}, status=503) + self.__handler = handler + + def set_handler(self, handler): + self.__handler = handler + + async def get_defaults(self, request): + return await self.__handler(request) + def main(): - profile_handler = ProfileHandler() - profile_handler.Load() + get_defaults = GetDefaults() if NO_SERIAL: serial_handler = FakeSerialHandler() @@ -371,7 +382,7 @@ def main(): serial_handler = SerialHandler(port=SERIAL_PORT, timeout=0.05) async def on_startup(app): - asyncio.create_task(run_websockets(app=app, serial_handler=serial_handler, profile_handler=profile_handler)) + asyncio.create_task(run_websockets(app=app, serial_handler=serial_handler, get_defaults=get_defaults)) app = web.Application() @@ -381,7 +392,7 @@ async def on_startup(app): app['websocket-tasks'] = set() app.add_routes([ - web.get('/defaults', make_get_defaults(profile_handler)), + web.get('/defaults', get_defaults.get_defaults), web.get('/ws', get_ws), ]) if not NO_SERIAL: From 43d9ef3d3600dd3592ba47df946f99c47aee7e27 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Wed, 9 Feb 2022 20:08:28 -0500 Subject: [PATCH 19/56] Get panel count from MCU --- webui/server/server.py | 55 +++++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 9997e58..e2e6169 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -17,9 +17,6 @@ SERIAL_PORT = "/dev/ttyACM0" HTTP_PORT = 5000 -# Amount of panels. -num_panels = 4 - # Used for developmental purposes. Set this to true when you just want to # emulate the serial device instead of actually connecting to one. NO_SERIAL = False @@ -44,12 +41,13 @@ class ProfileHandler(object): loaded: bool, whether or not the backend has already loaded the profile data file or not. """ - def __init__(self, filename='profiles.txt'): + def __init__(self, num_panels, filename='profiles.txt'): + self.num_panels = num_panels self.filename = filename self.profiles = OrderedDict() self.cur_profile = '' # Have a default no-name profile we can use in case there are no profiles. - self.profiles[''] = [0] * num_panels + self.profiles[''] = [0] * self.num_panels def __PersistProfiles(self): with open(self.filename, 'w') as f: @@ -63,7 +61,7 @@ def Load(self): with open(self.filename, 'r') as f: for line in f: parts = line.split() - if len(parts) == (num_panels+1): + if len(parts) == (self.num_panels + 1): self.profiles[parts[0]] = [int(x) for x in parts[1:]] num_profiles += 1 # Change to the first profile found. @@ -96,7 +94,7 @@ def GetProfileNames(self): def AddProfile(self, profile_name, thresholds): self.profiles[profile_name] = thresholds if self.cur_profile == '': - self.profiles[''] = [0] * num_panels + self.profiles[''] = [0] * self.num_panels self.ChangeProfile(profile_name) self.__PersistProfiles() @@ -113,12 +111,13 @@ def GetCurrentProfile(self): return self.cur_profile class FakeSerialHandler(object): - def __init__(self): + def __init__(self, num_panels=4): self.__is_open = False + self.__num_panels = num_panels # Use this to store the values when emulating serial so the graph isn't too - # jumpy. Only used when NO_SERIAL is true. - self.__no_serial_values = [0] * num_panels - self.__thresholds = [0] * num_panels + # jumpy. + self.__no_serial_values = [0] * self.__num_panels + self.__thresholds = [0] * self.__num_panels def Open(self): self.__is_open = True @@ -131,10 +130,10 @@ def isOpen(self): def Send(self, command): if command == 'v\n': - offsets = [int(normalvariate(0, num_panels+1)) for _ in range(num_panels)] + offsets = [int(normalvariate(0, self.__num_panels + 1)) for _ in range(self.__num_panels)] self.__no_serial_values = [ max(0, min(self.__no_serial_values[i] + offsets[i], 1023)) - for i in range(num_panels) + for i in range(self.__num_panels) ] return 'v', self.__no_serial_values.copy() elif command == 't\n': @@ -147,6 +146,9 @@ def Send(self, command): class CommandFormatError(Exception): pass +class SerialTimeoutError(Exception): + pass + class SerialHandler(object): """ A class to handle all the serial interactions. @@ -179,25 +181,24 @@ def Send(self, command): line = self.ser.readline().decode('ascii') if not line.endswith('\n'): - raise TimeoutError('Timeout reading response to command. {} {}'.format(command, line)) + raise SerialTimeoutError('Timeout reading response to command. {} {}'.format(command, line)) line = line.strip() # All commands are of the form: # cmd num1 num2 num3 num4 parts = line.split() - if len(parts) != num_panels + 1: - raise CommandFormatError('Command response "{}" had length {}, expected length was {}'.format(line, len(parts), num_panels + 1)) + # if len(parts) != num_panels + 1: + # raise CommandFormatError('Command response "{}" had length {}, expected length was {}'.format(line, len(parts), num_panels + 1)) cmd = parts[0] values = [int(x) for x in parts[1:]] return cmd, values async def run_websockets(app, serial_handler, get_defaults): - profile_handler = ProfileHandler() - profile_handler.Load() - global serial_connected + profile_handler = None + async def send_json_all(msg): websockets = app['websockets'].copy() for ws in websockets: @@ -292,6 +293,11 @@ async def task_loop(): await asyncio.to_thread(lambda: serial_handler.Open()) print('Serial connected') serial_connected = True + # Retrieve current thresholds on connect, and establish number of panels + t, thresholds = await asyncio.to_thread(lambda: serial_handler.Send('t\n')) + profile_handler = ProfileHandler(num_panels=len(thresholds)) + profile_handler.Load() + # Handle to GET /defaults using new profile_handler async def get_defaults_handler(request): return json_response({ 'profiles': profile_handler.GetProfileNames(), @@ -299,15 +305,10 @@ async def get_defaults_handler(request): 'thresholds': profile_handler.GetCurThresholds() }) get_defaults.set_handler(get_defaults_handler) - # Send current thresholds on connect - cur_thresholds = profile_handler.GetCurThresholds() - for i, threshold in enumerate(cur_thresholds): - threshold_cmd = str(i) + str(threshold) + '\n' - t, thresholds = await asyncio.to_thread(lambda: serial_handler.Send(threshold_cmd)) - if not str(cur_thresholds) == str(thresholds): - print('Microcontroller did not save thresholds. Profile: {}, MCU: {}'.format(str(cur_thresholds), str(thresholds))) - sys.exit(1) await task_loop() + except SerialTimeoutError as e: + logger.exception('Serial timeout: %s', e) + continue except serial.SerialException as e: # In case of serial error, disconnect all clients. The WebUI will try to reconnect. serial_handler.Close() From d2d8420986440324baf6a52374b58906d45c4dd2 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Wed, 9 Feb 2022 20:35:37 -0500 Subject: [PATCH 20/56] Save response from MCU when setting thresholds --- webui/server/server.py | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index e2e6169..7bf85fe 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -76,12 +76,20 @@ def GetCurThresholds(self): raise RuntimeError("Current profile name is missing from profile list") return self.profiles[self.cur_profile] - def UpdateThresholds(self, index, value): + def UpdateThreshold(self, index, value): if not self.cur_profile in self.profiles: raise RuntimeError("Current profile name is missing from profile list") self.profiles[self.cur_profile][index] = value self.__PersistProfiles() + def UpdateThresholds(self, values): + if not self.cur_profile in self.profiles: + raise RuntimeError("Current profile name is missing from profile list") + if not len(values) == len(self.profiles[self.cur_profile]): + raise RuntimeError('Expected {} threshold values, got {}'.format(len(self.profiles[self.cur_profile]), values)) + self.profiles[self.cur_profile] = values.copy() + self.__PersistProfiles() + def ChangeProfile(self, profile_name): if not profile_name in self.profiles: print(profile_name, " not in ", self.profiles) @@ -206,9 +214,17 @@ async def send_json_all(msg): await ws.send_json(msg) async def update_threshold(values, index): - profile_handler.UpdateThresholds(index, values[index]) threshold_cmd = str(index) + str(values[index]) + '\n' - await asyncio.to_thread(lambda: serial_handler.Send(threshold_cmd)) + t, thresholds = await asyncio.to_thread(lambda: serial_handler.Send(threshold_cmd)) + profile_handler.UpdateThreshold(index, thresholds[index]) + await send_json_all(['thresholds', {'thresholds': profile_handler.GetCurThresholds()}]) + print('Thresholds are: ' + str(profile_handler.GetCurThresholds())) + + async def update_thresholds(values): + for index, value in enumerate(values): + threshold_cmd = str(index) + str(value) + '\n' + t, thresholds = await asyncio.to_thread(lambda: serial_handler.Send(threshold_cmd)) + profile_handler.UpdateThresholds(thresholds) await send_json_all(['thresholds', {'thresholds': profile_handler.GetCurThresholds()}]) print('Thresholds are: ' + str(profile_handler.GetCurThresholds())) @@ -227,8 +243,7 @@ async def remove_profile(profile_name): profile_handler.RemoveProfile(profile_name) # Need to apply the thresholds of the profile we've fallen back to. thresholds = profile_handler.GetCurThresholds() - for i in range(len(thresholds)): - await update_threshold(thresholds, i) + update_thresholds(thresholds) await send_json_all(['get_profiles', {'profiles': profile_handler.GetProfileNames()}]) await send_json_all(['get_cur_profile', {'cur_profile': profile_handler.GetCurrentProfile()}]) print('Removed profile "{}". Current thresholds are: {}'.format( @@ -238,8 +253,7 @@ async def change_profile(profile_name): profile_handler.ChangeProfile(profile_name) # Need to apply the thresholds of the profile we've changed to. thresholds = profile_handler.GetCurThresholds() - for i in range(len(thresholds)): - await update_threshold(thresholds, i) + update_thresholds(thresholds) await send_json_all(['get_cur_profile', {'cur_profile': profile_handler.GetCurrentProfile()}]) print('Changed to profile "{}" with thresholds: {}'.format( profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) @@ -296,7 +310,14 @@ async def task_loop(): # Retrieve current thresholds on connect, and establish number of panels t, thresholds = await asyncio.to_thread(lambda: serial_handler.Send('t\n')) profile_handler = ProfileHandler(num_panels=len(thresholds)) + + # Load profiles profile_handler.Load() + + # Send current thresholds from loaded profile, then write back from MCU to profiles. + thresholds = profile_handler.GetCurThresholds() + await update_thresholds(thresholds) + # Handle to GET /defaults using new profile_handler async def get_defaults_handler(request): return json_response({ From 990ffca890c5817c2a10a5b7f5f37fcee3b7bd8d Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Wed, 9 Feb 2022 21:26:16 -0500 Subject: [PATCH 21/56] Change format of update threshold command Separate index and threshold with a space to support 2 digit sensor indexes --- README.md | 2 +- fsr.ino | 33 ++++++++++++++++++++++----------- webui/server/server.py | 4 ++-- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index a410c41..71c8620 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Follow a guide like [fsr-pad-guide](https://github.com/Sereni/fsr-pad-guide) or ### Testing and using the serial monitor 1. Open `Tools` > `Serial Monitor` to open the Serial Monitor 1. Within the serial monitor, enter `t` to show current thresholds. -1. You can change a sensor threshold by entering numbers, where the first number is the sensor (0-indexed) followed by the threshold value. For example, `3180` would set the 4th sensor to 180 thresholds. You can change these more easily in the UI later. +1. You can change a sensor threshold by entering numbers, where the first number is the sensor (0-indexed) followed by the threshold value. For example, `3 180` would set the 4th sensor to 180 thresholds. You can change these more easily in the UI later. 1. Enter `v` to get the current sensor values. 1. Putting pressure on an FSR, you should notice the values change if you enter `v` again while maintaining pressure. diff --git a/fsr.ino b/fsr.ino index 17174b1..80506b6 100644 --- a/fsr.ino +++ b/fsr.ino @@ -473,8 +473,18 @@ class SerialProcessor { case 'T': PrintThresholds(); break; - default: + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': UpdateAndPrintThreshold(bytes_read); + default: break; } } @@ -482,19 +492,20 @@ class SerialProcessor { void UpdateAndPrintThreshold(size_t bytes_read) { // Need to specify: - // Sensor number + Threshold value. - // {0, 1, 2, 3} + "0"-"1023" - // e.g. 3180 (fourth FSR, change threshold to 180) - - if (bytes_read < 2 || bytes_read > 5) { return; } + // Sensor number + Threshold value, separated by a space. + // {0, 1, 2, 3,...} + "0"-"1023" + // e.g. 3 180 (fourth FSR, change threshold to 180) - size_t sensor_index = buffer_[0] - '0'; - //this works for chars < '0' because they will - //also be > kNumSensors due to uint underflow. + if (bytes_read < 3 || bytes_read > 7) { return; } + + char* next = nullptr; + size_t sensor_index = strtoul(buffer_, &next, 10); if (sensor_index >= kNumSensors) { return; } - kSensors[sensor_index].UpdateThreshold( - strtoul(buffer_ + 1, nullptr, 10)); + int16_t sensor_threshold = strtol(next, nullptr, 10); + if (sensor_threshold < 0 || sensor_threshold > 1023) { return; } + + kSensors[sensor_index].UpdateThreshold(sensor_threshold); PrintThresholds(); } diff --git a/webui/server/server.py b/webui/server/server.py index 7bf85fe..be3fff4 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -214,7 +214,7 @@ async def send_json_all(msg): await ws.send_json(msg) async def update_threshold(values, index): - threshold_cmd = str(index) + str(values[index]) + '\n' + threshold_cmd = str(index) + ' ' + str(values[index]) + '\n' t, thresholds = await asyncio.to_thread(lambda: serial_handler.Send(threshold_cmd)) profile_handler.UpdateThreshold(index, thresholds[index]) await send_json_all(['thresholds', {'thresholds': profile_handler.GetCurThresholds()}]) @@ -222,7 +222,7 @@ async def update_threshold(values, index): async def update_thresholds(values): for index, value in enumerate(values): - threshold_cmd = str(index) + str(value) + '\n' + threshold_cmd = str(index) + ' ' + str(value) + '\n' t, thresholds = await asyncio.to_thread(lambda: serial_handler.Send(threshold_cmd)) profile_handler.UpdateThresholds(thresholds) await send_json_all(['thresholds', {'thresholds': profile_handler.GetCurThresholds()}]) From eb6aa83a0cc75cc1605e769efdb3c7240c988550 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Fri, 11 Feb 2022 22:24:27 -0500 Subject: [PATCH 22/56] Refactor /defaults handler class --- webui/server/server.py | 48 +++++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index be3fff4..05ceebf 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -203,7 +203,7 @@ def Send(self, command): return cmd, values -async def run_websockets(app, serial_handler, get_defaults): +async def run_websockets(app, serial_handler, defaults_handler): global serial_connected profile_handler = None @@ -318,14 +318,8 @@ async def task_loop(): thresholds = profile_handler.GetCurThresholds() await update_thresholds(thresholds) - # Handle to GET /defaults using new profile_handler - async def get_defaults_handler(request): - return json_response({ - 'profiles': profile_handler.GetProfileNames(), - 'cur_profile': profile_handler.GetCurrentProfile(), - 'thresholds': profile_handler.GetCurThresholds() - }) - get_defaults.set_handler(get_defaults_handler) + # Handle GET /defaults using new profile_handler + defaults_handler.set_profile_handler(profile_handler) await task_loop() except SerialTimeoutError as e: logger.exception('Serial timeout: %s', e) @@ -335,7 +329,7 @@ async def get_defaults_handler(request): serial_handler.Close() logger.exception('Serial error: %s', e) serial_connected = False - get_defaults.reset_handler() + defaults_handler.set_profile_handler(None) async with websockets_lock: for task in app['websocket-tasks']: task.cancel() @@ -380,23 +374,25 @@ async def on_shutdown(app): for task in app['websocket-tasks']: task.cancel() -class GetDefaults(object): +class DefaultsHandler(object): def __init__(self): - self.reset_handler() - - def reset_handler(self): - async def handler(request): - json_response({}, status=503) - self.__handler = handler - - def set_handler(self, handler): - self.__handler = handler - - async def get_defaults(self, request): - return await self.__handler(request) + self.__profile_handler = None + + def set_profile_handler(self, profile_handler): + self.__profile_handler = profile_handler + + async def handle_defaults(self, request): + if self.__profile_handler: + return json_response({ + 'profiles': self.__profile_handler.GetProfileNames(), + 'cur_profile': self.__profile_handler.GetCurrentProfile(), + 'thresholds': self.__profile_handler.GetCurThresholds() + }) + else: + return json_response({}, status=503) def main(): - get_defaults = GetDefaults() + defaults_handler = DefaultsHandler() if NO_SERIAL: serial_handler = FakeSerialHandler() @@ -404,7 +400,7 @@ def main(): serial_handler = SerialHandler(port=SERIAL_PORT, timeout=0.05) async def on_startup(app): - asyncio.create_task(run_websockets(app=app, serial_handler=serial_handler, get_defaults=get_defaults)) + asyncio.create_task(run_websockets(app=app, serial_handler=serial_handler, defaults_handler=defaults_handler)) app = web.Application() @@ -414,7 +410,7 @@ async def on_startup(app): app['websocket-tasks'] = set() app.add_routes([ - web.get('/defaults', get_defaults.get_defaults), + web.get('/defaults', defaults_handler.handle_defaults), web.get('/ws', get_ws), ]) if not NO_SERIAL: From bb4d7786fbef6de17649bddb951c3be24b432c0b Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Fri, 11 Feb 2022 23:01:32 -0500 Subject: [PATCH 23/56] Move websocket handler function into class --- webui/server/server.py | 91 +++++++++++++++++++++++------------------- 1 file changed, 50 insertions(+), 41 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 54dd36a..365cd13 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -21,12 +21,6 @@ # emulate the serial device instead of actually connecting to one. NO_SERIAL = False -# Track whether there is an active serial connection -serial_connected = False - -# Queue for websocket Tasks to pass messages they receive from clients to the run_websockets task. -receive_queue = asyncio.Queue(maxsize=1) - # Used to coordinate updates to app['websockets'] set websockets_lock = asyncio.Lock() @@ -203,8 +197,7 @@ def Send(self, command): return cmd, values -async def run_websockets(app, serial_handler, defaults_handler): - global serial_connected +async def run_websockets(app, websocket_handler, serial_handler, defaults_handler): profile_handler = None async def send_json_all(msg): @@ -272,9 +265,9 @@ async def report_values(values): async def task_loop(): poll_values_task = asyncio.create_task(asyncio.sleep(poll_values_wait_seconds)) - receive_queue_task = asyncio.create_task(receive_queue.get()) + receive_json_task = asyncio.create_task(websocket_handler.receive_json()) while True: - done, pending = await asyncio.wait([poll_values_task, receive_queue_task], return_when=asyncio.FIRST_COMPLETED) + done, pending = await asyncio.wait([poll_values_task, receive_json_task], return_when=asyncio.FIRST_COMPLETED) for task in done: if task == poll_values_task: @@ -282,7 +275,7 @@ async def task_loop(): v, values = await get_values() await report_values(values) poll_values_task = asyncio.create_task(asyncio.sleep(poll_values_wait_seconds)) - if task == receive_queue_task: + if task == receive_json_task: data = await task action = data[0] @@ -299,14 +292,14 @@ async def task_loop(): elif action == 'change_profile': profile_name, = data[1:] await change_profile(profile_name) - receive_queue.task_done() - receive_queue_task = asyncio.create_task(receive_queue.get()) + websocket_handler.task_done() + receive_json_task = asyncio.create_task(websocket_handler.receive_json()) while True: try: await asyncio.to_thread(lambda: serial_handler.Open()) print('Serial connected') - serial_connected = True + websocket_handler.serial_connected = True # Retrieve current thresholds on connect, and establish number of panels t, thresholds = await asyncio.to_thread(lambda: serial_handler.Send('t\n')) profile_handler = ProfileHandler(num_sensors=len(thresholds)) @@ -328,39 +321,54 @@ async def task_loop(): # In case of serial error, disconnect all clients. The WebUI will try to reconnect. serial_handler.Close() logger.exception('Serial error: %s', e) - serial_connected = False + websocket_handler.serial_connected = False defaults_handler.set_profile_handler(None) async with websockets_lock: for task in app['websocket-tasks']: task.cancel() await asyncio.sleep(3) -async def get_ws(request): - if not serial_connected: - return json_response({}, status=503) - this_task = asyncio.current_task() - ws = web.WebSocketResponse() - await ws.prepare(request) +class WebSocketHandler(object): + def __init__(self): + # Queue to pass messages to main Task + self.__receive_queue = asyncio.Queue(maxsize=1) + # Set when connecting or disconnecting serial device. + self.serial_connected = False + + async def receive_json(self): + return await self.__receive_queue.get() + + # Call after processing the json from receive_json + def task_done(self): + self.__receive_queue.task_done() + + async def handle_ws(self, request): + if not self.serial_connected: + return json_response({}, status=503) + + this_task = asyncio.current_task() + ws = web.WebSocketResponse() + await ws.prepare(request) - async with websockets_lock: - request.app['websockets'].add(ws) - request.app['websocket-tasks'].add(this_task) - print('Client connected') - - try: - while not ws.closed: - msg = await ws.receive() - if msg.type == WSMsgType.CLOSE: - break - elif msg.type == WSMsgType.TEXT: - data = msg.json() - await receive_queue.put(data) - finally: async with websockets_lock: - request.app['websockets'].remove(ws) - request.app['websocket-tasks'].remove(this_task) - await ws.close() - print('Client disconnected') + request.app['websockets'].add(ws) + request.app['websocket-tasks'].add(this_task) + print('Client connected') + + try: + while not ws.closed: + msg = await ws.receive() + if msg.type == WSMsgType.CLOSE: + break + elif msg.type == WSMsgType.TEXT: + data = msg.json() + await self.__receive_queue.put(data) + finally: + async with websockets_lock: + request.app['websockets'].remove(ws) + request.app['websocket-tasks'].remove(this_task) + await ws.close() + print('Client disconnected') build_dir = os.path.abspath( os.path.join(os.path.dirname(__file__), '..', 'build') @@ -393,6 +401,7 @@ async def handle_defaults(self, request): def main(): defaults_handler = DefaultsHandler() + websocket_handler = WebSocketHandler() if NO_SERIAL: serial_handler = FakeSerialHandler() @@ -400,7 +409,7 @@ def main(): serial_handler = SerialHandler(port=SERIAL_PORT, timeout=0.05) async def on_startup(app): - asyncio.create_task(run_websockets(app=app, serial_handler=serial_handler, defaults_handler=defaults_handler)) + asyncio.create_task(run_websockets(app=app, websocket_handler=websocket_handler, serial_handler=serial_handler, defaults_handler=defaults_handler)) app = web.Application() @@ -411,7 +420,7 @@ async def on_startup(app): app.add_routes([ web.get('/defaults', defaults_handler.handle_defaults), - web.get('/ws', get_ws), + web.get('/ws', websocket_handler.handle_ws), ]) if not NO_SERIAL: app.add_routes([ From c13e3260a23f0954af86daf3416c94cf0011186f Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Fri, 11 Feb 2022 23:25:37 -0500 Subject: [PATCH 24/56] Keep lock and websocket sets in WebsocketHandler --- webui/server/server.py | 90 ++++++++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 42 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 365cd13..7cea75a 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -21,9 +21,6 @@ # emulate the serial device instead of actually connecting to one. NO_SERIAL = False -# Used to coordinate updates to app['websockets'] set -websockets_lock = asyncio.Lock() - class ProfileHandler(object): """ A class to handle all the profile modifications. @@ -200,17 +197,11 @@ def Send(self, command): async def run_websockets(app, websocket_handler, serial_handler, defaults_handler): profile_handler = None - async def send_json_all(msg): - websockets = app['websockets'].copy() - for ws in websockets: - if not ws.closed: - await ws.send_json(msg) - async def update_threshold(values, index): threshold_cmd = str(index) + ' ' + str(values[index]) + '\n' t, thresholds = await asyncio.to_thread(lambda: serial_handler.Send(threshold_cmd)) profile_handler.UpdateThreshold(index, thresholds[index]) - await send_json_all(['thresholds', {'thresholds': profile_handler.GetCurThresholds()}]) + await websocket_handler.send_json_all(['thresholds', {'thresholds': profile_handler.GetCurThresholds()}]) print('Thresholds are: ' + str(profile_handler.GetCurThresholds())) async def update_thresholds(values): @@ -218,17 +209,17 @@ async def update_thresholds(values): threshold_cmd = str(index) + ' ' + str(value) + '\n' t, thresholds = await asyncio.to_thread(lambda: serial_handler.Send(threshold_cmd)) profile_handler.UpdateThresholds(thresholds) - await send_json_all(['thresholds', {'thresholds': profile_handler.GetCurThresholds()}]) + await websocket_handler.send_json_all(['thresholds', {'thresholds': profile_handler.GetCurThresholds()}]) print('Thresholds are: ' + str(profile_handler.GetCurThresholds())) async def add_profile(profile_name, thresholds): profile_handler.AddProfile(profile_name, thresholds) # When we add a profile, we are using the currently loaded thresholds so we # don't need to explicitly apply anything. - await send_json_all(['get_profiles', {'profiles': profile_handler.GetProfileNames()}]) + await websocket_handler.send_json_all(['get_profiles', {'profiles': profile_handler.GetProfileNames()}]) print('Added profile "{}" with thresholds: {}'.format( profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) - await send_json_all(['get_cur_profile', {'cur_profile': profile_handler.GetCurrentProfile()}]) + await websocket_handler.send_json_all(['get_cur_profile', {'cur_profile': profile_handler.GetCurrentProfile()}]) print('Changed to profile "{}" with thresholds: {}'.format( profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) @@ -236,9 +227,9 @@ async def remove_profile(profile_name): profile_handler.RemoveProfile(profile_name) # Need to apply the thresholds of the profile we've fallen back to. thresholds = profile_handler.GetCurThresholds() - update_thresholds(thresholds) - await send_json_all(['get_profiles', {'profiles': profile_handler.GetProfileNames()}]) - await send_json_all(['get_cur_profile', {'cur_profile': profile_handler.GetCurrentProfile()}]) + await update_thresholds(thresholds) + await websocket_handler.send_json_all(['get_profiles', {'profiles': profile_handler.GetProfileNames()}]) + await websocket_handler.send_json_all(['get_cur_profile', {'cur_profile': profile_handler.GetCurrentProfile()}]) print('Removed profile "{}". Current thresholds are: {}'.format( profile_name, str(profile_handler.GetCurThresholds()))) @@ -246,8 +237,8 @@ async def change_profile(profile_name): profile_handler.ChangeProfile(profile_name) # Need to apply the thresholds of the profile we've changed to. thresholds = profile_handler.GetCurThresholds() - update_thresholds(thresholds) - await send_json_all(['get_cur_profile', {'cur_profile': profile_handler.GetCurrentProfile()}]) + await update_thresholds(thresholds) + await websocket_handler.send_json_all(['get_cur_profile', {'cur_profile': profile_handler.GetCurrentProfile()}]) print('Changed to profile "{}" with thresholds: {}'.format( profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) @@ -259,7 +250,7 @@ async def get_values(): sys.exit(1) async def report_values(values): - await send_json_all(['values', {'values': values}]) + await websocket_handler.send_json_all(['values', {'values': values}]) poll_values_wait_seconds = 0.01 @@ -271,7 +262,7 @@ async def task_loop(): for task in done: if task == poll_values_task: - if len(app['websockets']) > 0: + if websocket_handler.has_clients(): v, values = await get_values() await report_values(values) poll_values_task = asyncio.create_task(asyncio.sleep(poll_values_wait_seconds)) @@ -323,18 +314,24 @@ async def task_loop(): logger.exception('Serial error: %s', e) websocket_handler.serial_connected = False defaults_handler.set_profile_handler(None) - async with websockets_lock: - for task in app['websocket-tasks']: - task.cancel() + await websocket_handler.cancel_ws_tasks() await asyncio.sleep(3) class WebSocketHandler(object): def __init__(self): - # Queue to pass messages to main Task - self.__receive_queue = asyncio.Queue(maxsize=1) # Set when connecting or disconnecting serial device. self.serial_connected = False - + # Queue to pass messages to main Task + self.__receive_queue = asyncio.Queue(maxsize=1) + # Used to coordinate updates to app['websockets'] set + self.__websockets_lock = asyncio.Lock() + # Set of open websockets used to broadcast messages to all clients. + self.__websockets = set() + # Set of open websocket tasks to cancel when the app shuts down. + self.__websocket_tasks = set() + + # Only the task that opens a websocket is allowed to call receive on it, + # so call receive in the handler task and queue messages for other coroutines to read. async def receive_json(self): return await self.__receive_queue.get() @@ -342,6 +339,22 @@ async def receive_json(self): def task_done(self): self.__receive_queue.task_done() + async def send_json_all(self, msg): + # Iterate over copy of set in case the set is modified while awaiting a send + websockets = self.__websockets.copy() + for ws in websockets: + if not ws.closed: + await ws.send_json(msg) + + async def cancel_ws_tasks(self): + async with self.__websockets_lock: + for task in self.__websocket_tasks: + task.cancel() + + def has_clients(self): + return len(self.__websockets) > 0 + + # Pass to router, this coroutine will be run in one task per connection async def handle_ws(self, request): if not self.serial_connected: return json_response({}, status=503) @@ -350,9 +363,9 @@ async def handle_ws(self, request): ws = web.WebSocketResponse() await ws.prepare(request) - async with websockets_lock: - request.app['websockets'].add(ws) - request.app['websocket-tasks'].add(this_task) + async with self.__websockets_lock: + self.__websockets.add(ws) + self.__websocket_tasks.add(this_task) print('Client connected') try: @@ -364,9 +377,9 @@ async def handle_ws(self, request): data = msg.json() await self.__receive_queue.put(data) finally: - async with websockets_lock: - request.app['websockets'].remove(ws) - request.app['websocket-tasks'].remove(this_task) + async with self.__websockets_lock: + self.__websockets.remove(ws) + self.__websocket_tasks.remove(this_task) await ws.close() print('Client disconnected') @@ -377,11 +390,6 @@ async def handle_ws(self, request): async def get_index(request): return web.FileResponse(os.path.join(build_dir, 'index.html')) -async def on_shutdown(app): - async with websockets_lock: - for task in app['websocket-tasks']: - task.cancel() - class DefaultsHandler(object): def __init__(self): self.__profile_handler = None @@ -411,12 +419,10 @@ def main(): async def on_startup(app): asyncio.create_task(run_websockets(app=app, websocket_handler=websocket_handler, serial_handler=serial_handler, defaults_handler=defaults_handler)) - app = web.Application() + async def on_shutdown(app): + await websocket_handler.cancel_ws_tasks() - # Set of open websockets used to broadcast messages to all clients. - app['websockets'] = set() - # Set of open websocket tasks to cancel when the app shuts down. - app['websocket-tasks'] = set() + app = web.Application() app.add_routes([ web.get('/defaults', defaults_handler.handle_defaults), From f00c90e1a5d096f6bc16014e1dca631addfc809b Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Fri, 11 Feb 2022 23:45:43 -0500 Subject: [PATCH 25/56] Remove unused app parameter for run_websockets --- webui/server/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 7cea75a..5c3db79 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -194,7 +194,7 @@ def Send(self, command): return cmd, values -async def run_websockets(app, websocket_handler, serial_handler, defaults_handler): +async def run_websockets(websocket_handler, serial_handler, defaults_handler): profile_handler = None async def update_threshold(values, index): @@ -417,7 +417,7 @@ def main(): serial_handler = SerialHandler(port=SERIAL_PORT, timeout=0.05) async def on_startup(app): - asyncio.create_task(run_websockets(app=app, websocket_handler=websocket_handler, serial_handler=serial_handler, defaults_handler=defaults_handler)) + asyncio.create_task(run_websockets(websocket_handler=websocket_handler, serial_handler=serial_handler, defaults_handler=defaults_handler)) async def on_shutdown(app): await websocket_handler.cancel_ws_tasks() From eb7d713afaa70fde2aab3d2c13faa6946aed166a Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sat, 12 Feb 2022 23:23:25 -0500 Subject: [PATCH 26/56] Fix nesting of task_done call --- webui/server/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webui/server/server.py b/webui/server/server.py index 5c3db79..afecda5 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -283,7 +283,7 @@ async def task_loop(): elif action == 'change_profile': profile_name, = data[1:] await change_profile(profile_name) - websocket_handler.task_done() + websocket_handler.task_done() receive_json_task = asyncio.create_task(websocket_handler.receive_json()) while True: From ad649127609ed11d10ba0c4032f622397e26ae96 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sat, 12 Feb 2022 23:56:54 -0500 Subject: [PATCH 27/56] Move serial commands to new class --- webui/server/server.py | 90 ++++++++++++++++++++++++++++-------------- 1 file changed, 60 insertions(+), 30 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index afecda5..56e5e85 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -134,13 +134,13 @@ def Send(self, command): max(0, min(self.__no_serial_values[i] + offsets[i], 1023)) for i in range(self.__num_sensors) ] - return 'v', self.__no_serial_values.copy() + return 'v %s' % (' '.join(map(str, self.__no_serial_values))) elif command == 't\n': - return 't', self.__thresholds.copy() + return 't %s' % (' '.join(map(str, self.__thresholds))) elif "0123456789".find(command[0]) != -1: sensor_index = int(command[0]) self.__thresholds[sensor_index] = int(command[1:]) - return 't', self.__thresholds.copy() + return 't %s' % (' '.join(map(str, self.__thresholds))) class CommandFormatError(Exception): pass @@ -182,32 +182,69 @@ def Send(self, command): if not line.endswith('\n'): raise SerialTimeoutError('Timeout reading response to command. {} {}'.format(command, line)) - line = line.strip() + return line.strip() - # All commands are of the form: - # cmd num1 num2 num3 num4 - parts = line.split() - # if len(parts) != num_sensors + 1: - # raise CommandFormatError('Command response "{}" had length {}, expected length was {}'.format(line, len(parts), num_sensors + 1)) - cmd = parts[0] - values = [int(x) for x in parts[1:]] - return cmd, values +class FsrSerialHandler(object): + def __init__(self, serial_handler): + self.__serial_handler = serial_handler + + def Open(self): + self.__serial_handler.Open() + + def Close(self): + self.__serial_handler.Close() + + def isOpen(self): + return self.__serial_handler.isOpen() + + async def get_values(self): + response = await asyncio.to_thread(lambda: self.__serial_handler.Send('v\n')) + # Expect current sensor values preceded by a 'v'. + # v num1 num2 num3 num4 + parts = response.split() + if parts[0] != 'v': + raise CommandFormatError('Expected values in response, got "{}"' % (response)) + return [int(x) for x in parts[1:]] + + async def get_thresholds(self): + response = await asyncio.to_thread(lambda: self.__serial_handler.Send('t\n')) + # Expect current thresholds preceded by a 't'. + # t num1 num2 num3 num4 + parts = response.split() + if parts[0] != 't': + raise CommandFormatError('Expected thresholds in response, got "{}"' % (response)) + return [int(x) for x in parts[1:]] + + async def update_threshold(self, index, threshold): + threshold_cmd = '%d %d\n' % (index, threshold) + response = await asyncio.to_thread(lambda: self.__serial_handler.Send(threshold_cmd)) + # Expect updated thresholds preceded by a 't'. + # t num1 num2 num3 num4 + parts = response.split() + if parts[0] != 't': + raise CommandFormatError('Expected thresholds in response, got "{}"' % (response)) + return [int(x) for x in parts[1:]] + + async def update_thresholds(self, thresholds): + """ + Update multiple thresholds. Return the new thresholds after the final update. + """ + for index, threshold in enumerate(thresholds): + new_thresholds = await self.update_threshold(index, threshold) + return new_thresholds async def run_websockets(websocket_handler, serial_handler, defaults_handler): profile_handler = None async def update_threshold(values, index): - threshold_cmd = str(index) + ' ' + str(values[index]) + '\n' - t, thresholds = await asyncio.to_thread(lambda: serial_handler.Send(threshold_cmd)) - profile_handler.UpdateThreshold(index, thresholds[index]) + thresholds = await serial_handler.update_threshold(index, values[index]) + profile_handler.UpdateThresholds(thresholds) await websocket_handler.send_json_all(['thresholds', {'thresholds': profile_handler.GetCurThresholds()}]) print('Thresholds are: ' + str(profile_handler.GetCurThresholds())) async def update_thresholds(values): - for index, value in enumerate(values): - threshold_cmd = str(index) + ' ' + str(value) + '\n' - t, thresholds = await asyncio.to_thread(lambda: serial_handler.Send(threshold_cmd)) + thresholds = await serial_handler.update_thresholds(values) profile_handler.UpdateThresholds(thresholds) await websocket_handler.send_json_all(['thresholds', {'thresholds': profile_handler.GetCurThresholds()}]) print('Thresholds are: ' + str(profile_handler.GetCurThresholds())) @@ -241,13 +278,6 @@ async def change_profile(profile_name): await websocket_handler.send_json_all(['get_cur_profile', {'cur_profile': profile_handler.GetCurrentProfile()}]) print('Changed to profile "{}" with thresholds: {}'.format( profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) - - async def get_values(): - try: - return await asyncio.to_thread(lambda: serial_handler.Send('v\n')) - except CommandFormatError as e: - logger.exception("Bad response from v command: %s", e) - sys.exit(1) async def report_values(values): await websocket_handler.send_json_all(['values', {'values': values}]) @@ -263,7 +293,7 @@ async def task_loop(): for task in done: if task == poll_values_task: if websocket_handler.has_clients(): - v, values = await get_values() + values = await serial_handler.get_values() await report_values(values) poll_values_task = asyncio.create_task(asyncio.sleep(poll_values_wait_seconds)) if task == receive_json_task: @@ -292,10 +322,10 @@ async def task_loop(): print('Serial connected') websocket_handler.serial_connected = True # Retrieve current thresholds on connect, and establish number of panels - t, thresholds = await asyncio.to_thread(lambda: serial_handler.Send('t\n')) + thresholds = await serial_handler.get_thresholds() profile_handler = ProfileHandler(num_sensors=len(thresholds)) - # Load profiles + # Load saved profiles profile_handler.Load() # Send current thresholds from loaded profile, then write back from MCU to profiles. @@ -412,9 +442,9 @@ def main(): websocket_handler = WebSocketHandler() if NO_SERIAL: - serial_handler = FakeSerialHandler() + serial_handler = FsrSerialHandler(FakeSerialHandler()) else: - serial_handler = SerialHandler(port=SERIAL_PORT, timeout=0.05) + serial_handler = FsrSerialHandler(SerialHandler(port=SERIAL_PORT, timeout=0.05)) async def on_startup(app): asyncio.create_task(run_websockets(websocket_handler=websocket_handler, serial_handler=serial_handler, defaults_handler=defaults_handler)) From f387f9330b6a7f254c1081aa7ab9272a1d71694a Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sun, 13 Feb 2022 00:12:30 -0500 Subject: [PATCH 28/56] Added broadcast helpers in WebSocketHandler --- webui/server/server.py | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 56e5e85..4b4d36e 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -240,24 +240,22 @@ async def run_websockets(websocket_handler, serial_handler, defaults_handler): async def update_threshold(values, index): thresholds = await serial_handler.update_threshold(index, values[index]) profile_handler.UpdateThresholds(thresholds) - await websocket_handler.send_json_all(['thresholds', {'thresholds': profile_handler.GetCurThresholds()}]) + await websocket_handler.broadcast_thresholds(profile_handler.GetCurThresholds()) print('Thresholds are: ' + str(profile_handler.GetCurThresholds())) async def update_thresholds(values): thresholds = await serial_handler.update_thresholds(values) profile_handler.UpdateThresholds(thresholds) - await websocket_handler.send_json_all(['thresholds', {'thresholds': profile_handler.GetCurThresholds()}]) + await websocket_handler.broadcast_thresholds(profile_handler.GetCurThresholds()) print('Thresholds are: ' + str(profile_handler.GetCurThresholds())) async def add_profile(profile_name, thresholds): profile_handler.AddProfile(profile_name, thresholds) # When we add a profile, we are using the currently loaded thresholds so we # don't need to explicitly apply anything. - await websocket_handler.send_json_all(['get_profiles', {'profiles': profile_handler.GetProfileNames()}]) - print('Added profile "{}" with thresholds: {}'.format( - profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) - await websocket_handler.send_json_all(['get_cur_profile', {'cur_profile': profile_handler.GetCurrentProfile()}]) - print('Changed to profile "{}" with thresholds: {}'.format( + await websocket_handler.broadcast_profiles(profile_handler.GetProfileNames()) + await websocket_handler.broadcast_cur_profile(profile_handler.GetCurrentProfile()) + print('Changed to new profile "{}" with thresholds: {}'.format( profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) async def remove_profile(profile_name): @@ -265,8 +263,8 @@ async def remove_profile(profile_name): # Need to apply the thresholds of the profile we've fallen back to. thresholds = profile_handler.GetCurThresholds() await update_thresholds(thresholds) - await websocket_handler.send_json_all(['get_profiles', {'profiles': profile_handler.GetProfileNames()}]) - await websocket_handler.send_json_all(['get_cur_profile', {'cur_profile': profile_handler.GetCurrentProfile()}]) + await websocket_handler.broadcast_profiles(profile_handler.GetProfileNames()) + await websocket_handler.broadcast_cur_profile(profile_handler.GetCurrentProfile()) print('Removed profile "{}". Current thresholds are: {}'.format( profile_name, str(profile_handler.GetCurThresholds()))) @@ -275,12 +273,9 @@ async def change_profile(profile_name): # Need to apply the thresholds of the profile we've changed to. thresholds = profile_handler.GetCurThresholds() await update_thresholds(thresholds) - await websocket_handler.send_json_all(['get_cur_profile', {'cur_profile': profile_handler.GetCurrentProfile()}]) + await websocket_handler.broadcast_cur_profile(profile_handler.GetCurrentProfile()) print('Changed to profile "{}" with thresholds: {}'.format( profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) - - async def report_values(values): - await websocket_handler.send_json_all(['values', {'values': values}]) poll_values_wait_seconds = 0.01 @@ -294,7 +289,7 @@ async def task_loop(): if task == poll_values_task: if websocket_handler.has_clients(): values = await serial_handler.get_values() - await report_values(values) + await websocket_handler.broadcast_values(values) poll_values_task = asyncio.create_task(asyncio.sleep(poll_values_wait_seconds)) if task == receive_json_task: data = await task @@ -375,6 +370,22 @@ async def send_json_all(self, msg): for ws in websockets: if not ws.closed: await ws.send_json(msg) + + async def broadcast_thresholds(self, thresholds): + """Send current thresholds to all connected clients""" + await self.send_json_all(['thresholds', {'thresholds': thresholds}]) + + async def broadcast_values(self, values): + """Send current sensor values to all connected clients""" + await self.send_json_all(['values', {'values': values}]) + + async def broadcast_profiles(self, profiles): + """Send list of profile names to all connected clients""" + await self.send_json_all(['get_profiles', {'profiles': profiles}]) + + async def broadcast_cur_profile(self, cur_profile): + """Send name of current profile to all connected clients""" + await self.send_json_all(['get_cur_profile', {'cur_profile': cur_profile}]) async def cancel_ws_tasks(self): async with self.__websockets_lock: From 3c3ac4b881a7170442f911caf2ec8a94a55f94e8 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sun, 13 Feb 2022 00:28:22 -0500 Subject: [PATCH 29/56] Make some info logging more specific --- webui/server/server.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 4b4d36e..8737459 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -60,7 +60,6 @@ def Load(self): self.ChangeProfile(parts[0]) else: open(self.filename, 'w').close() - print('Found Profiles: ' + str(list(self.profiles.keys()))) def GetCurThresholds(self): if not self.cur_profile in self.profiles: @@ -241,13 +240,15 @@ async def update_threshold(values, index): thresholds = await serial_handler.update_threshold(index, values[index]) profile_handler.UpdateThresholds(thresholds) await websocket_handler.broadcast_thresholds(profile_handler.GetCurThresholds()) - print('Thresholds are: ' + str(profile_handler.GetCurThresholds())) + print('Profile is "{}". Thresholds are: {}'.format( + profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) async def update_thresholds(values): thresholds = await serial_handler.update_thresholds(values) profile_handler.UpdateThresholds(thresholds) await websocket_handler.broadcast_thresholds(profile_handler.GetCurThresholds()) - print('Thresholds are: ' + str(profile_handler.GetCurThresholds())) + print('Profile is "{}". Thresholds are: {}'.format( + profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) async def add_profile(profile_name, thresholds): profile_handler.AddProfile(profile_name, thresholds) @@ -255,7 +256,7 @@ async def add_profile(profile_name, thresholds): # don't need to explicitly apply anything. await websocket_handler.broadcast_profiles(profile_handler.GetProfileNames()) await websocket_handler.broadcast_cur_profile(profile_handler.GetCurrentProfile()) - print('Changed to new profile "{}" with thresholds: {}'.format( + print('Changed to new profile "{}". Thresholds are: {}'.format( profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) async def remove_profile(profile_name): @@ -265,8 +266,8 @@ async def remove_profile(profile_name): await update_thresholds(thresholds) await websocket_handler.broadcast_profiles(profile_handler.GetProfileNames()) await websocket_handler.broadcast_cur_profile(profile_handler.GetCurrentProfile()) - print('Removed profile "{}". Current thresholds are: {}'.format( - profile_name, str(profile_handler.GetCurThresholds()))) + print('Removed profile "{}" and changed to profile "{}". Thresholds are: {}'.format( + profile_name, profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) async def change_profile(profile_name): profile_handler.ChangeProfile(profile_name) @@ -274,7 +275,7 @@ async def change_profile(profile_name): thresholds = profile_handler.GetCurThresholds() await update_thresholds(thresholds) await websocket_handler.broadcast_cur_profile(profile_handler.GetCurrentProfile()) - print('Changed to profile "{}" with thresholds: {}'.format( + print('Changed to profile "{}". Thresholds are: {}'.format( profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) poll_values_wait_seconds = 0.01 @@ -322,10 +323,14 @@ async def task_loop(): # Load saved profiles profile_handler.Load() + print('Found Profiles: ' + str(list(profile_handler.GetProfileNames()))) # Send current thresholds from loaded profile, then write back from MCU to profiles. thresholds = profile_handler.GetCurThresholds() - await update_thresholds(thresholds) + thresholds = await serial_handler.update_thresholds(thresholds) + profile_handler.UpdateThresholds(thresholds) + print('Profile is "{}". Thresholds are: {}'.format( + profile_handler.GetCurrentProfile(), profile_handler.GetCurThresholds())) # Handle GET /defaults using new profile_handler defaults_handler.set_profile_handler(profile_handler) From 0910c9d24ee391f3708688317c8445b367ad8c2e Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sun, 13 Feb 2022 00:45:24 -0500 Subject: [PATCH 30/56] Extract handle_client_message function --- webui/server/server.py | 72 ++++++++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 8737459..c04a077 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -278,40 +278,22 @@ async def change_profile(profile_name): print('Changed to profile "{}". Thresholds are: {}'.format( profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) - poll_values_wait_seconds = 0.01 - - async def task_loop(): - poll_values_task = asyncio.create_task(asyncio.sleep(poll_values_wait_seconds)) - receive_json_task = asyncio.create_task(websocket_handler.receive_json()) - while True: - done, pending = await asyncio.wait([poll_values_task, receive_json_task], return_when=asyncio.FIRST_COMPLETED) - - for task in done: - if task == poll_values_task: - if websocket_handler.has_clients(): - values = await serial_handler.get_values() - await websocket_handler.broadcast_values(values) - poll_values_task = asyncio.create_task(asyncio.sleep(poll_values_wait_seconds)) - if task == receive_json_task: - data = await task - - action = data[0] - - if action == 'update_threshold': - values, index = data[1:] - await update_threshold(values, index) - elif action == 'add_profile': - profile_name, thresholds = data[1:] - await add_profile(profile_name, thresholds) - elif action == 'remove_profile': - profile_name, = data[1:] - await remove_profile(profile_name) - elif action == 'change_profile': - profile_name, = data[1:] - await change_profile(profile_name) - websocket_handler.task_done() - receive_json_task = asyncio.create_task(websocket_handler.receive_json()) - + async def handle_client_message(data): + action = data[0] + + if action == 'update_threshold': + values, index = data[1:] + await update_threshold(values, index) + elif action == 'add_profile': + profile_name, thresholds = data[1:] + await add_profile(profile_name, thresholds) + elif action == 'remove_profile': + profile_name, = data[1:] + await remove_profile(profile_name) + elif action == 'change_profile': + profile_name, = data[1:] + await change_profile(profile_name) + while True: try: await asyncio.to_thread(lambda: serial_handler.Open()) @@ -334,7 +316,27 @@ async def task_loop(): # Handle GET /defaults using new profile_handler defaults_handler.set_profile_handler(profile_handler) - await task_loop() + + # Minimum delay in seconds to wait betwen getting current sensor values + POLL_VALUES_WAIT_SECONDS = 1.0 / 100 + + # Poll sensor values and handle client message + poll_values_task = asyncio.create_task(asyncio.sleep(POLL_VALUES_WAIT_SECONDS)) + receive_json_task = asyncio.create_task(websocket_handler.receive_json()) + while True: + done, pending = await asyncio.wait([poll_values_task, receive_json_task], return_when=asyncio.FIRST_COMPLETED) + for task in done: + if task == poll_values_task: + if websocket_handler.has_clients(): + values = await serial_handler.get_values() + await websocket_handler.broadcast_values(values) + poll_values_task = asyncio.create_task(asyncio.sleep(POLL_VALUES_WAIT_SECONDS)) + if task == receive_json_task: + data = await task + await handle_client_message(data) + websocket_handler.task_done() + receive_json_task = asyncio.create_task(websocket_handler.receive_json()) + except SerialTimeoutError as e: logger.exception('Serial timeout: %s', e) continue From c66ac7a5ef2b230255e55173a19338d53909f732 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sun, 13 Feb 2022 01:21:19 -0500 Subject: [PATCH 31/56] Handle serial timeout like other serial errors --- webui/server/server.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index c04a077..27eeddc 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -144,7 +144,7 @@ def Send(self, command): class CommandFormatError(Exception): pass -class SerialTimeoutError(Exception): +class SerialReadTimeoutError(Exception): pass class SerialHandler(object): @@ -179,7 +179,7 @@ def Send(self, command): line = self.ser.readline().decode('ascii') if not line.endswith('\n'): - raise SerialTimeoutError('Timeout reading response to command. {} {}'.format(command, line)) + raise SerialReadTimeoutError('Timeout reading response to command. {} {}'.format(command, line)) return line.strip() @@ -337,10 +337,7 @@ async def handle_client_message(data): websocket_handler.task_done() receive_json_task = asyncio.create_task(websocket_handler.receive_json()) - except SerialTimeoutError as e: - logger.exception('Serial timeout: %s', e) - continue - except serial.SerialException as e: + except (serial.SerialException, SerialReadTimeoutError) as e: # In case of serial error, disconnect all clients. The WebUI will try to reconnect. serial_handler.Close() logger.exception('Serial error: %s', e) From 8563f8b79d8b5bc0df01dbd72537a1690af71d56 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sun, 13 Feb 2022 01:29:10 -0500 Subject: [PATCH 32/56] Update remove_profile log message --- webui/server/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webui/server/server.py b/webui/server/server.py index 27eeddc..4167222 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -266,7 +266,7 @@ async def remove_profile(profile_name): await update_thresholds(thresholds) await websocket_handler.broadcast_profiles(profile_handler.GetProfileNames()) await websocket_handler.broadcast_cur_profile(profile_handler.GetCurrentProfile()) - print('Removed profile "{}" and changed to profile "{}". Thresholds are: {}'.format( + print('Removed profile "{}". Profile is "{}". Thresholds are: {}'.format( profile_name, profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) async def change_profile(profile_name): From 434447cf870f1bb92f9304af4a7e9c3386b4416a Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sun, 13 Feb 2022 01:30:25 -0500 Subject: [PATCH 33/56] Remove empty line --- webui/server/server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/webui/server/server.py b/webui/server/server.py index 4167222..052d74c 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -280,7 +280,6 @@ async def change_profile(profile_name): async def handle_client_message(data): action = data[0] - if action == 'update_threshold': values, index = data[1:] await update_threshold(values, index) From e05437a4325d03a164148dd8fd378c8caef27b51 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sun, 13 Feb 2022 02:02:51 -0500 Subject: [PATCH 34/56] Reorder some definitions in server.py --- webui/server/server.py | 214 ++++++++++++++++++++--------------------- 1 file changed, 107 insertions(+), 107 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 052d74c..58e9a56 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -21,6 +21,15 @@ # emulate the serial device instead of actually connecting to one. NO_SERIAL = False +class CommandFormatError(Exception): + """Serial responded but command was not in the expected format.""" + +class SerialReadTimeoutError(Exception): + """ + Serial response did not end in a newline, + presumably because read operation timed out before receiving one. + """ + class ProfileHandler(object): """ A class to handle all the profile modifications. @@ -29,8 +38,6 @@ class ProfileHandler(object): filename: string, the filename where to read/write profile data. profiles: OrderedDict, the profile data loaded from the file. cur_profile: string, the name of the current active profile. - loaded: bool, whether or not the backend has already loaded the - profile data file or not. """ def __init__(self, num_sensors, filename='profiles.txt'): self.num_sensors = num_sensors @@ -141,12 +148,6 @@ def Send(self, command): self.__thresholds[sensor_index] = int(command[1:]) return 't %s' % (' '.join(map(str, self.__thresholds))) -class CommandFormatError(Exception): - pass - -class SerialReadTimeoutError(Exception): - pass - class SerialHandler(object): """ A class to handle all the serial interactions. @@ -232,6 +233,104 @@ async def update_thresholds(self, thresholds): new_thresholds = await self.update_threshold(index, threshold) return new_thresholds +class WebSocketHandler(object): + def __init__(self): + # Set when connecting or disconnecting serial device. + self.serial_connected = False + # Queue to pass messages to main Task + self.__receive_queue = asyncio.Queue(maxsize=1) + # Used to coordinate updates to app['websockets'] set + self.__websockets_lock = asyncio.Lock() + # Set of open websockets used to broadcast messages to all clients. + self.__websockets = set() + # Set of open websocket tasks to cancel when the app shuts down. + self.__websocket_tasks = set() + + # Only the task that opens a websocket is allowed to call receive on it, + # so call receive in the handler task and queue messages for other coroutines to read. + async def receive_json(self): + return await self.__receive_queue.get() + + # Call after processing the json from receive_json + def task_done(self): + self.__receive_queue.task_done() + + async def send_json_all(self, msg): + # Iterate over copy of set in case the set is modified while awaiting a send + websockets = self.__websockets.copy() + for ws in websockets: + if not ws.closed: + await ws.send_json(msg) + + async def broadcast_thresholds(self, thresholds): + """Send current thresholds to all connected clients""" + await self.send_json_all(['thresholds', {'thresholds': thresholds}]) + + async def broadcast_values(self, values): + """Send current sensor values to all connected clients""" + await self.send_json_all(['values', {'values': values}]) + + async def broadcast_profiles(self, profiles): + """Send list of profile names to all connected clients""" + await self.send_json_all(['get_profiles', {'profiles': profiles}]) + + async def broadcast_cur_profile(self, cur_profile): + """Send name of current profile to all connected clients""" + await self.send_json_all(['get_cur_profile', {'cur_profile': cur_profile}]) + + async def cancel_ws_tasks(self): + async with self.__websockets_lock: + for task in self.__websocket_tasks: + task.cancel() + + def has_clients(self): + return len(self.__websockets) > 0 + + # Pass to router, this coroutine will be run in one task per connection + async def handle_ws(self, request): + if not self.serial_connected: + return json_response({}, status=503) + + this_task = asyncio.current_task() + ws = web.WebSocketResponse() + await ws.prepare(request) + + async with self.__websockets_lock: + self.__websockets.add(ws) + self.__websocket_tasks.add(this_task) + print('Client connected') + + try: + while not ws.closed: + msg = await ws.receive() + if msg.type == WSMsgType.CLOSE: + break + elif msg.type == WSMsgType.TEXT: + data = msg.json() + await self.__receive_queue.put(data) + finally: + async with self.__websockets_lock: + self.__websockets.remove(ws) + self.__websocket_tasks.remove(this_task) + await ws.close() + print('Client disconnected') + +class DefaultsHandler(object): + def __init__(self): + self.__profile_handler = None + + def set_profile_handler(self, profile_handler): + self.__profile_handler = profile_handler + + async def handle_defaults(self, request): + if self.__profile_handler: + return json_response({ + 'profiles': self.__profile_handler.GetProfileNames(), + 'cur_profile': self.__profile_handler.GetCurrentProfile(), + 'thresholds': self.__profile_handler.GetCurThresholds() + }) + else: + return json_response({}, status=503) async def run_websockets(websocket_handler, serial_handler, defaults_handler): profile_handler = None @@ -345,88 +444,6 @@ async def handle_client_message(data): await websocket_handler.cancel_ws_tasks() await asyncio.sleep(3) -class WebSocketHandler(object): - def __init__(self): - # Set when connecting or disconnecting serial device. - self.serial_connected = False - # Queue to pass messages to main Task - self.__receive_queue = asyncio.Queue(maxsize=1) - # Used to coordinate updates to app['websockets'] set - self.__websockets_lock = asyncio.Lock() - # Set of open websockets used to broadcast messages to all clients. - self.__websockets = set() - # Set of open websocket tasks to cancel when the app shuts down. - self.__websocket_tasks = set() - - # Only the task that opens a websocket is allowed to call receive on it, - # so call receive in the handler task and queue messages for other coroutines to read. - async def receive_json(self): - return await self.__receive_queue.get() - - # Call after processing the json from receive_json - def task_done(self): - self.__receive_queue.task_done() - - async def send_json_all(self, msg): - # Iterate over copy of set in case the set is modified while awaiting a send - websockets = self.__websockets.copy() - for ws in websockets: - if not ws.closed: - await ws.send_json(msg) - - async def broadcast_thresholds(self, thresholds): - """Send current thresholds to all connected clients""" - await self.send_json_all(['thresholds', {'thresholds': thresholds}]) - - async def broadcast_values(self, values): - """Send current sensor values to all connected clients""" - await self.send_json_all(['values', {'values': values}]) - - async def broadcast_profiles(self, profiles): - """Send list of profile names to all connected clients""" - await self.send_json_all(['get_profiles', {'profiles': profiles}]) - - async def broadcast_cur_profile(self, cur_profile): - """Send name of current profile to all connected clients""" - await self.send_json_all(['get_cur_profile', {'cur_profile': cur_profile}]) - - async def cancel_ws_tasks(self): - async with self.__websockets_lock: - for task in self.__websocket_tasks: - task.cancel() - - def has_clients(self): - return len(self.__websockets) > 0 - - # Pass to router, this coroutine will be run in one task per connection - async def handle_ws(self, request): - if not self.serial_connected: - return json_response({}, status=503) - - this_task = asyncio.current_task() - ws = web.WebSocketResponse() - await ws.prepare(request) - - async with self.__websockets_lock: - self.__websockets.add(ws) - self.__websocket_tasks.add(this_task) - print('Client connected') - - try: - while not ws.closed: - msg = await ws.receive() - if msg.type == WSMsgType.CLOSE: - break - elif msg.type == WSMsgType.TEXT: - data = msg.json() - await self.__receive_queue.put(data) - finally: - async with self.__websockets_lock: - self.__websockets.remove(ws) - self.__websocket_tasks.remove(this_task) - await ws.close() - print('Client disconnected') - build_dir = os.path.abspath( os.path.join(os.path.dirname(__file__), '..', 'build') ) @@ -434,23 +451,6 @@ async def handle_ws(self, request): async def get_index(request): return web.FileResponse(os.path.join(build_dir, 'index.html')) -class DefaultsHandler(object): - def __init__(self): - self.__profile_handler = None - - def set_profile_handler(self, profile_handler): - self.__profile_handler = profile_handler - - async def handle_defaults(self, request): - if self.__profile_handler: - return json_response({ - 'profiles': self.__profile_handler.GetProfileNames(), - 'cur_profile': self.__profile_handler.GetCurrentProfile(), - 'thresholds': self.__profile_handler.GetCurThresholds() - }) - else: - return json_response({}, status=503) - def main(): defaults_handler = DefaultsHandler() websocket_handler = WebSocketHandler() From fcaf9bac67b4e8dd564b3f9a1c087b03b57c1a78 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sun, 13 Feb 2022 02:12:36 -0500 Subject: [PATCH 35/56] Rename run_websockets to run_main_task_loop --- webui/server/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 58e9a56..8713431 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -332,7 +332,7 @@ async def handle_defaults(self, request): else: return json_response({}, status=503) -async def run_websockets(websocket_handler, serial_handler, defaults_handler): +async def run_main_task_loop(websocket_handler, serial_handler, defaults_handler): profile_handler = None async def update_threshold(values, index): @@ -461,7 +461,7 @@ def main(): serial_handler = FsrSerialHandler(SerialHandler(port=SERIAL_PORT, timeout=0.05)) async def on_startup(app): - asyncio.create_task(run_websockets(websocket_handler=websocket_handler, serial_handler=serial_handler, defaults_handler=defaults_handler)) + asyncio.create_task(run_main_task_loop(websocket_handler=websocket_handler, serial_handler=serial_handler, defaults_handler=defaults_handler)) async def on_shutdown(app): await websocket_handler.cancel_ws_tasks() From 9f8846298bf4546f94aaaa80373277e9faee3b41 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sun, 13 Feb 2022 11:07:15 -0500 Subject: [PATCH 36/56] Update method and property names in serial classes --- webui/server/server.py | 141 +++++++++++++++++++++-------------------- 1 file changed, 74 insertions(+), 67 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 8713431..55f6177 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -116,89 +116,96 @@ def GetCurrentProfile(self): return self.cur_profile class FakeSerialHandler(object): - def __init__(self, num_sensors=4): - self.__is_open = False - self.__num_sensors = num_sensors - # Use this to store the values when emulating serial so the graph isn't too - # jumpy. - self.__no_serial_values = [0] * self.__num_sensors - self.__thresholds = [0] * self.__num_sensors - - def Open(self): - self.__is_open = True - - def Close(self): - self.__is_open = False - - def isOpen(self): - return self.__is_open - - def Send(self, command): - if command == 'v\n': - offsets = [int(normalvariate(0, self.__num_sensors + 1)) for _ in range(self.__num_sensors)] - self.__no_serial_values = [ - max(0, min(self.__no_serial_values[i] + offsets[i], 1023)) - for i in range(self.__num_sensors) - ] - return 'v %s' % (' '.join(map(str, self.__no_serial_values))) - elif command == 't\n': - return 't %s' % (' '.join(map(str, self.__thresholds))) - elif "0123456789".find(command[0]) != -1: - sensor_index = int(command[0]) - self.__thresholds[sensor_index] = int(command[1:]) - return 't %s' % (' '.join(map(str, self.__thresholds))) + def __init__(self, num_sensors): + self._is_open = False + self._num_sensors = num_sensors + # Use previous values when randomly generating sensor readings + # so graph isn't too jumpy. + self._sensor_values = [0] * self._num_sensors + self._thresholds = [0] * self._num_sensors -class SerialHandler(object): + async def open(self): + self._is_open = True + + def close(self): + self._is_open = False + + def is_open(self): + return self._is_open + + async def get_values(self): + offsets = [int(normalvariate(0, self._num_sensors + 1)) for _ in range(self._num_sensors)] + self._sensor_values = [ + max(0, min(self._sensor_values[i] + offsets[i], 1023)) + for i in range(self._num_sensors) + ] + return self._sensor_values.copy() + + async def get_thresholds(self): + return self._sensor_values.copy() + + async def update_threshold(self, index, threshold): + self._thresholds[index] = threshold + return self._thresholds.copy() + + async def update_thresholds(self, thresholds): + """ + Update multiple thresholds. Return the new thresholds after the final update. + """ + self._thresholds = thresholds.copy() + return self._thresholds.copy() + +class SyncSerialSender(object): + """ + Send and receive serial commands one line at at time. """ - A class to handle all the serial interactions. - Attributes: - ser: Serial, the serial object opened by this class. + def __init__(self, port, timeout=1): + """ port: string, the path/name of the serial object to open. timeout: int, the time in seconds indicating the timeout for serial operations. - """ - def __init__(self, port, timeout=1): - self.ser = None - self.port = port - self.timeout = timeout + """ + self._ser = None + self._port = port + self._timeout = timeout - def Open(self): - self.ser = serial.Serial(self.port, 115200, timeout=self.timeout) + def open(self): + self._ser = serial.Serial(self._port, 115200, timeout=self._timeout) - def Close(self): - if self.ser and not self.ser.closed: - self.ser.close() - self.ser = None + def close(self): + if self._ser and not self._ser.closed: + self._ser.close() + self._ser = None - def isOpen(self): - return self.ser and self.ser.isOpen() + def is_open(self): + return self._ser and self._ser.isOpen() - def Send(self, command): - self.ser.write(command.encode()) + def send(self, command): + self._ser.write(command.encode()) - line = self.ser.readline().decode('ascii') + line = self._ser.readline().decode('ascii') if not line.endswith('\n'): raise SerialReadTimeoutError('Timeout reading response to command. {} {}'.format(command, line)) return line.strip() -class FsrSerialHandler(object): - def __init__(self, serial_handler): - self.__serial_handler = serial_handler +class SerialHandler(object): + def __init__(self, sync_serial_sender): + self._sync_serial_sender = sync_serial_sender - def Open(self): - self.__serial_handler.Open() + async def open(self): + self._sync_serial_sender.open() - def Close(self): - self.__serial_handler.Close() + def close(self): + self._sync_serial_sender.close() - def isOpen(self): - return self.__serial_handler.isOpen() + def is_open(self): + return self._sync_serial_sender.is_open() async def get_values(self): - response = await asyncio.to_thread(lambda: self.__serial_handler.Send('v\n')) + response = await asyncio.to_thread(lambda: self._sync_serial_sender.send('v\n')) # Expect current sensor values preceded by a 'v'. # v num1 num2 num3 num4 parts = response.split() @@ -207,7 +214,7 @@ async def get_values(self): return [int(x) for x in parts[1:]] async def get_thresholds(self): - response = await asyncio.to_thread(lambda: self.__serial_handler.Send('t\n')) + response = await asyncio.to_thread(lambda: self._sync_serial_sender.send('t\n')) # Expect current thresholds preceded by a 't'. # t num1 num2 num3 num4 parts = response.split() @@ -217,7 +224,7 @@ async def get_thresholds(self): async def update_threshold(self, index, threshold): threshold_cmd = '%d %d\n' % (index, threshold) - response = await asyncio.to_thread(lambda: self.__serial_handler.Send(threshold_cmd)) + response = await asyncio.to_thread(lambda: self._sync_serial_sender.send(threshold_cmd)) # Expect updated thresholds preceded by a 't'. # t num1 num2 num3 num4 parts = response.split() @@ -394,7 +401,7 @@ async def handle_client_message(data): while True: try: - await asyncio.to_thread(lambda: serial_handler.Open()) + await serial_handler.open() print('Serial connected') websocket_handler.serial_connected = True # Retrieve current thresholds on connect, and establish number of panels @@ -437,7 +444,7 @@ async def handle_client_message(data): except (serial.SerialException, SerialReadTimeoutError) as e: # In case of serial error, disconnect all clients. The WebUI will try to reconnect. - serial_handler.Close() + serial_handler.close() logger.exception('Serial error: %s', e) websocket_handler.serial_connected = False defaults_handler.set_profile_handler(None) @@ -456,9 +463,9 @@ def main(): websocket_handler = WebSocketHandler() if NO_SERIAL: - serial_handler = FsrSerialHandler(FakeSerialHandler()) + serial_handler = FakeSerialHandler(num_sensors=4) else: - serial_handler = FsrSerialHandler(SerialHandler(port=SERIAL_PORT, timeout=0.05)) + serial_handler = SerialHandler(SyncSerialSender(port=SERIAL_PORT, timeout=0.05)) async def on_startup(app): asyncio.create_task(run_main_task_loop(websocket_handler=websocket_handler, serial_handler=serial_handler, defaults_handler=defaults_handler)) From 632dd9bfe221f73183687c075eaf57f2f58de5ab Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sun, 13 Feb 2022 11:10:27 -0500 Subject: [PATCH 37/56] Single leading underscore in private properties --- webui/server/server.py | 56 +++++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 55f6177..0455fe1 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -47,7 +47,7 @@ def __init__(self, num_sensors, filename='profiles.txt'): # Have a default no-name profile we can use in case there are no profiles. self.profiles[''] = [0] * self.num_sensors - def __PersistProfiles(self): + def _PersistProfiles(self): with open(self.filename, 'w') as f: for name, thresholds in self.profiles.items(): if name: @@ -77,7 +77,7 @@ def UpdateThreshold(self, index, value): if not self.cur_profile in self.profiles: raise RuntimeError("Current profile name is missing from profile list") self.profiles[self.cur_profile][index] = value - self.__PersistProfiles() + self._PersistProfiles() def UpdateThresholds(self, values): if not self.cur_profile in self.profiles: @@ -85,7 +85,7 @@ def UpdateThresholds(self, values): if not len(values) == len(self.profiles[self.cur_profile]): raise RuntimeError('Expected {} threshold values, got {}'.format(len(self.profiles[self.cur_profile]), values)) self.profiles[self.cur_profile] = values.copy() - self.__PersistProfiles() + self._PersistProfiles() def ChangeProfile(self, profile_name): if not profile_name in self.profiles: @@ -101,7 +101,7 @@ def AddProfile(self, profile_name, thresholds): if self.cur_profile == '': self.profiles[''] = [0] * self.num_sensors self.ChangeProfile(profile_name) - self.__PersistProfiles() + self._PersistProfiles() def RemoveProfile(self, profile_name): if not profile_name in self.profiles: @@ -110,7 +110,7 @@ def RemoveProfile(self, profile_name): del self.profiles[profile_name] if profile_name == self.cur_profile: self.ChangeProfile('') - self.__PersistProfiles() + self._PersistProfiles() def GetCurrentProfile(self): return self.cur_profile @@ -245,26 +245,26 @@ def __init__(self): # Set when connecting or disconnecting serial device. self.serial_connected = False # Queue to pass messages to main Task - self.__receive_queue = asyncio.Queue(maxsize=1) + self._receive_queue = asyncio.Queue(maxsize=1) # Used to coordinate updates to app['websockets'] set - self.__websockets_lock = asyncio.Lock() + self._websockets_lock = asyncio.Lock() # Set of open websockets used to broadcast messages to all clients. - self.__websockets = set() + self._websockets = set() # Set of open websocket tasks to cancel when the app shuts down. - self.__websocket_tasks = set() + self._websocket_tasks = set() # Only the task that opens a websocket is allowed to call receive on it, # so call receive in the handler task and queue messages for other coroutines to read. async def receive_json(self): - return await self.__receive_queue.get() + return await self._receive_queue.get() # Call after processing the json from receive_json def task_done(self): - self.__receive_queue.task_done() + self._receive_queue.task_done() async def send_json_all(self, msg): # Iterate over copy of set in case the set is modified while awaiting a send - websockets = self.__websockets.copy() + websockets = self._websockets.copy() for ws in websockets: if not ws.closed: await ws.send_json(msg) @@ -286,12 +286,12 @@ async def broadcast_cur_profile(self, cur_profile): await self.send_json_all(['get_cur_profile', {'cur_profile': cur_profile}]) async def cancel_ws_tasks(self): - async with self.__websockets_lock: - for task in self.__websocket_tasks: + async with self._websockets_lock: + for task in self._websocket_tasks: task.cancel() def has_clients(self): - return len(self.__websockets) > 0 + return len(self._websockets) > 0 # Pass to router, this coroutine will be run in one task per connection async def handle_ws(self, request): @@ -302,9 +302,9 @@ async def handle_ws(self, request): ws = web.WebSocketResponse() await ws.prepare(request) - async with self.__websockets_lock: - self.__websockets.add(ws) - self.__websocket_tasks.add(this_task) + async with self._websockets_lock: + self._websockets.add(ws) + self._websocket_tasks.add(this_task) print('Client connected') try: @@ -314,27 +314,27 @@ async def handle_ws(self, request): break elif msg.type == WSMsgType.TEXT: data = msg.json() - await self.__receive_queue.put(data) + await self._receive_queue.put(data) finally: - async with self.__websockets_lock: - self.__websockets.remove(ws) - self.__websocket_tasks.remove(this_task) + async with self._websockets_lock: + self._websockets.remove(ws) + self._websocket_tasks.remove(this_task) await ws.close() print('Client disconnected') class DefaultsHandler(object): def __init__(self): - self.__profile_handler = None + self._profile_handler = None def set_profile_handler(self, profile_handler): - self.__profile_handler = profile_handler + self._profile_handler = profile_handler async def handle_defaults(self, request): - if self.__profile_handler: + if self._profile_handler: return json_response({ - 'profiles': self.__profile_handler.GetProfileNames(), - 'cur_profile': self.__profile_handler.GetCurrentProfile(), - 'thresholds': self.__profile_handler.GetCurThresholds() + 'profiles': self._profile_handler.GetProfileNames(), + 'cur_profile': self._profile_handler.GetCurrentProfile(), + 'thresholds': self._profile_handler.GetCurThresholds() }) else: return json_response({}, status=503) From 3aaef1fc924da6661f38e9bdd84a1cf72da5f775 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sun, 13 Feb 2022 11:13:56 -0500 Subject: [PATCH 38/56] Use properties for getters --- webui/server/server.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 0455fe1..3a21d9c 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -130,6 +130,7 @@ async def open(self): def close(self): self._is_open = False + @property def is_open(self): return self._is_open @@ -178,8 +179,9 @@ def close(self): self._ser.close() self._ser = None + @property def is_open(self): - return self._ser and self._ser.isOpen() + return self._ser and self._ser.is_open def send(self, command): self._ser.write(command.encode()) @@ -201,8 +203,9 @@ async def open(self): def close(self): self._sync_serial_sender.close() + @property def is_open(self): - return self._sync_serial_sender.is_open() + return self._sync_serial_sender.is_open async def get_values(self): response = await asyncio.to_thread(lambda: self._sync_serial_sender.send('v\n')) @@ -290,6 +293,7 @@ async def cancel_ws_tasks(self): for task in self._websocket_tasks: task.cancel() + @property def has_clients(self): return len(self._websockets) > 0 @@ -432,7 +436,7 @@ async def handle_client_message(data): done, pending = await asyncio.wait([poll_values_task, receive_json_task], return_when=asyncio.FIRST_COMPLETED) for task in done: if task == poll_values_task: - if websocket_handler.has_clients(): + if websocket_handler.has_clients: values = await serial_handler.get_values() await websocket_handler.broadcast_values(values) poll_values_task = asyncio.create_task(asyncio.sleep(POLL_VALUES_WAIT_SECONDS)) From 8422f250f4a11eacd9336028388fbbbd7ccc4615 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sun, 13 Feb 2022 21:14:40 -0500 Subject: [PATCH 39/56] Remove unused import --- webui/server/server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/webui/server/server.py b/webui/server/server.py index 3a21d9c..7e3e7c2 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -3,7 +3,6 @@ import logging import os import socket -import sys from collections import OrderedDict from random import normalvariate From 643efabbd0dbd7be56bc59f80b7cdcb0dd4e13c9 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sun, 13 Feb 2022 21:28:43 -0500 Subject: [PATCH 40/56] More underscore prefixes for private fields --- webui/server/server.py | 64 +++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 7e3e7c2..5cc8189 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -39,80 +39,80 @@ class ProfileHandler(object): cur_profile: string, the name of the current active profile. """ def __init__(self, num_sensors, filename='profiles.txt'): - self.num_sensors = num_sensors - self.filename = filename - self.profiles = OrderedDict() - self.cur_profile = '' + self._num_sensors = num_sensors + self._filename = filename + self._profiles = OrderedDict() + self._cur_profile = '' # Have a default no-name profile we can use in case there are no profiles. - self.profiles[''] = [0] * self.num_sensors + self._profiles[''] = [0] * self._num_sensors def _PersistProfiles(self): - with open(self.filename, 'w') as f: - for name, thresholds in self.profiles.items(): + with open(self._filename, 'w') as f: + for name, thresholds in self._profiles.items(): if name: f.write(name + ' ' + ' '.join(map(str, thresholds)) + '\n') def Load(self): num_profiles = 0 - if os.path.exists(self.filename): - with open(self.filename, 'r') as f: + if os.path.exists(self._filename): + with open(self._filename, 'r') as f: for line in f: parts = line.split() - if len(parts) == (self.num_sensors + 1): - self.profiles[parts[0]] = [int(x) for x in parts[1:]] + if len(parts) == (self._num_sensors + 1): + self._profiles[parts[0]] = [int(x) for x in parts[1:]] num_profiles += 1 # Change to the first profile found. if num_profiles == 1: self.ChangeProfile(parts[0]) else: - open(self.filename, 'w').close() + open(self._filename, 'w').close() def GetCurThresholds(self): - if not self.cur_profile in self.profiles: + if not self._cur_profile in self._profiles: raise RuntimeError("Current profile name is missing from profile list") - return self.profiles[self.cur_profile] + return self._profiles[self._cur_profile] def UpdateThreshold(self, index, value): - if not self.cur_profile in self.profiles: + if not self._cur_profile in self._profiles: raise RuntimeError("Current profile name is missing from profile list") - self.profiles[self.cur_profile][index] = value + self._profiles[self._cur_profile][index] = value self._PersistProfiles() def UpdateThresholds(self, values): - if not self.cur_profile in self.profiles: + if not self._cur_profile in self._profiles: raise RuntimeError("Current profile name is missing from profile list") - if not len(values) == len(self.profiles[self.cur_profile]): - raise RuntimeError('Expected {} threshold values, got {}'.format(len(self.profiles[self.cur_profile]), values)) - self.profiles[self.cur_profile] = values.copy() + if not len(values) == len(self._profiles[self._cur_profile]): + raise RuntimeError('Expected {} threshold values, got {}'.format(len(self._profiles[self._cur_profile]), values)) + self._profiles[self._cur_profile] = values.copy() self._PersistProfiles() def ChangeProfile(self, profile_name): - if not profile_name in self.profiles: - print(profile_name, " not in ", self.profiles) + if not profile_name in self._profiles: + print(profile_name, " not in ", self._profiles) raise RuntimeError("Selected profile name is missing from profile list") - self.cur_profile = profile_name + self._cur_profile = profile_name def GetProfileNames(self): - return [name for name in self.profiles.keys() if name] + return [name for name in self._profiles.keys() if name] def AddProfile(self, profile_name, thresholds): - self.profiles[profile_name] = thresholds - if self.cur_profile == '': - self.profiles[''] = [0] * self.num_sensors + self._profiles[profile_name] = thresholds + if self._cur_profile == '': + self._profiles[''] = [0] * self._num_sensors self.ChangeProfile(profile_name) self._PersistProfiles() def RemoveProfile(self, profile_name): - if not profile_name in self.profiles: - print(profile_name, " not in ", self.profiles) + if not profile_name in self._profiles: + print(profile_name, " not in ", self._profiles) raise RuntimeError("Selected profile name is missing from profile list") - del self.profiles[profile_name] - if profile_name == self.cur_profile: + del self._profiles[profile_name] + if profile_name == self._cur_profile: self.ChangeProfile('') self._PersistProfiles() def GetCurrentProfile(self): - return self.cur_profile + return self._cur_profile class FakeSerialHandler(object): def __init__(self, num_sensors): From bf7a872a4e48209d8cf1c6a6e4e6d0eeebce6a9d Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sun, 13 Feb 2022 22:26:05 -0500 Subject: [PATCH 41/56] Docstrings, misc changes to ProfileHandler --- webui/server/server.py | 96 ++++++++++++++++++++++++++++++------------ 1 file changed, 69 insertions(+), 27 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 5cc8189..51ebdd5 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -31,14 +31,15 @@ class SerialReadTimeoutError(Exception): class ProfileHandler(object): """ - A class to handle all the profile modifications. - - Attributes: - filename: string, the filename where to read/write profile data. - profiles: OrderedDict, the profile data loaded from the file. - cur_profile: string, the name of the current active profile. + Track a list of profiles and which is the "current" one. Handle + saving and loading profiles from a text file. """ def __init__(self, num_sensors, filename='profiles.txt'): + """ + Keyword arguments: + num_sensors -- all profiles are expected to have this many sensors + filename -- relative path for file safe/load profiles (default 'profiles.txt') + """ self._num_sensors = num_sensors self._filename = filename self._profiles = OrderedDict() @@ -46,13 +47,25 @@ def __init__(self, num_sensors, filename='profiles.txt'): # Have a default no-name profile we can use in case there are no profiles. self._profiles[''] = [0] * self._num_sensors - def _PersistProfiles(self): + def _AssertThresholdsLength(self, thresholds): + """Raise error if thresholds list is not the expected length.""" + if not len(thresholds) == self._num_sensors: + raise ValueError('Expected {} threshold values, got {}'.format(self._num_sensors, thresholds)) + + def _Save(self): + """ + Save profiles to file. The empty-name '' profile is always skipped. + """ with open(self._filename, 'w') as f: for name, thresholds in self._profiles.items(): if name: f.write(name + ' ' + ' '.join(map(str, thresholds)) + '\n') def Load(self): + """ + Load profiles from file if it exists, and change the to the first profile found. + If no profiles are found, do not change the current profile. + """ num_profiles = 0 if os.path.exists(self._filename): with open(self._filename, 'r') as f: @@ -64,54 +77,83 @@ def Load(self): # Change to the first profile found. if num_profiles == 1: self.ChangeProfile(parts[0]) - else: - open(self._filename, 'w').close() def GetCurThresholds(self): - if not self._cur_profile in self._profiles: - raise RuntimeError("Current profile name is missing from profile list") + """Return thresholds of current profile.""" return self._profiles[self._cur_profile] def UpdateThreshold(self, index, value): - if not self._cur_profile in self._profiles: - raise RuntimeError("Current profile name is missing from profile list") + """ + Update one threshold in the current profile, and save profiles to file. + + Keyword arguments: + index -- threshold index to update + value -- new threshold value + """ self._profiles[self._cur_profile][index] = value - self._PersistProfiles() + self._Save() def UpdateThresholds(self, values): - if not self._cur_profile in self._profiles: - raise RuntimeError("Current profile name is missing from profile list") - if not len(values) == len(self._profiles[self._cur_profile]): - raise RuntimeError('Expected {} threshold values, got {}'.format(len(self._profiles[self._cur_profile]), values)) + """ + Update all thresholds in the current profile, and save profiles to file. + The number of values must match the configured num_panels. + + Keyword arguments: + thresholds -- list of new threshold values + """ + self._AssertThresholdsLength(values) self._profiles[self._cur_profile] = values.copy() - self._PersistProfiles() + self._Save() def ChangeProfile(self, profile_name): - if not profile_name in self._profiles: - print(profile_name, " not in ", self._profiles) - raise RuntimeError("Selected profile name is missing from profile list") - self._cur_profile = profile_name + """ + Change to a profile. If there is no profile by that name, + remain on the current profile. + """ + if profile_name in self._profiles: + self._cur_profile = profile_name + else: + print("Ignoring ChangeProfile, ", profile_name, " not in ", self._profiles) def GetProfileNames(self): + """ + Return list of all profile names. + Does not include the empty-name '' profile. + """ return [name for name in self._profiles.keys() if name] def AddProfile(self, profile_name, thresholds): + """ + If the current profile is the empty-name '' profile, reset thresholds to defaults. + Add a profile, change to it, and save profiles to file. + + Keyword arguments: + profile_name -- the name of the new profile + thresholds -- list of threshold values for the new profile + """ + self._AssertThresholdsLength(thresholds) self._profiles[profile_name] = thresholds if self._cur_profile == '': self._profiles[''] = [0] * self._num_sensors self.ChangeProfile(profile_name) - self._PersistProfiles() + self._Save() def RemoveProfile(self, profile_name): + """ + Delete a profile and save profiles to file. + Change to empty-name '' profile if deleted profile was the current profile. + Trying to delete an unknown profile will print a warning and do nothing. + """ if not profile_name in self._profiles: - print(profile_name, " not in ", self._profiles) - raise RuntimeError("Selected profile name is missing from profile list") + print("No profile named ", profile_name, " to delete in ", self._profiles) + return del self._profiles[profile_name] if profile_name == self._cur_profile: self.ChangeProfile('') - self._PersistProfiles() + self._Save() def GetCurrentProfile(self): + """Return current profile name.""" return self._cur_profile class FakeSerialHandler(object): From 6cff1ce66c5ec0fe6bc12f153ae0836908f4d16d Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Sun, 13 Feb 2022 23:43:29 -0500 Subject: [PATCH 42/56] Document serial classes --- webui/server/server.py | 93 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 81 insertions(+), 12 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 51ebdd5..b58545d 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -157,11 +157,19 @@ def GetCurrentProfile(self): return self._cur_profile class FakeSerialHandler(object): + """ + Use in place of SerialHandler to test without a real serial device. + Stores and returns thresholds as requested. + Returns random sensor values on each read. The previous sensor values + influence the next read so the graph isn't too jumpy. + """ def __init__(self, num_sensors): + """ + Keyword arguments: + num_sensors -- return this many values and thresholds + """ self._is_open = False self._num_sensors = num_sensors - # Use previous values when randomly generating sensor readings - # so graph isn't too jumpy. self._sensor_values = [0] * self._num_sensors self._thresholds = [0] * self._num_sensors @@ -191,51 +199,90 @@ async def update_threshold(self, index, threshold): return self._thresholds.copy() async def update_thresholds(self, thresholds): - """ - Update multiple thresholds. Return the new thresholds after the final update. - """ - self._thresholds = thresholds.copy() + for i, threshold in enumerate(thresholds): + self._thresholds[i] = threshold return self._thresholds.copy() class SyncSerialSender(object): """ Send and receive serial commands one line at at time. """ - - def __init__(self, port, timeout=1): + def __init__(self, port, timeout=1.0): """ - port: string, the path/name of the serial object to open. - timeout: int, the time in seconds indicating the timeout for serial - operations. + port -- string, the path/name of the serial object to open + timeout -- the time in seconds indicating the timeout for serial + operations (default 1.0) """ self._ser = None self._port = port self._timeout = timeout def open(self): + """ + Open a new Serial instance with configured port and timeout. + """ self._ser = serial.Serial(self._port, 115200, timeout=self._timeout) def close(self): + """ + Close the serial port if it is open. + Does nothing if port is already closed. + """ if self._ser and not self._ser.closed: self._ser.close() self._ser = None @property def is_open(self): + "Return True if serial port is open, false otherwise." return self._ser and self._ser.is_open def send(self, command): + """ + Write a command string, then read a response and return it as a string. + + This does blocking IO, so don't call it directly from a coroutine. + + Command and response are both expected to end with a newline character. + `send` does not add a newline to `command`. It does strip the newline from + the response. + + Raises SerialReadTimeoutError if there is no response before the configured + timeout. + + Keyword arguments: + command -- string to write to serial port + """ self._ser.write(command.encode()) line = self._ser.readline().decode('ascii') + # If readline does not find a newline character before the Serial + # instance's configured timeout, it will return whatever it has + # read so far. PySerial does not throw an exception, but we will. if not line.endswith('\n'): raise SerialReadTimeoutError('Timeout reading response to command. {} {}'.format(command, line)) return line.strip() class SerialHandler(object): + """ + Handle communication with the serial device. + + Provide async wrappers and command parsing on top of + SyncSerialSender's blocking string-based IO. + + Blocking IO is run in a thread pool using an asyncio helper. However, only + one coroutine is expected to be sending commands, one command at a time. + There is only one underlying hardware device, so any command needs to wait + for the previous command and response to be processed. + """ def __init__(self, sync_serial_sender): + """ + Keyword arguments: + sync_serial_sender -- SyncSerialSender instance to perform blocking reads + and writes of command strings + """ self._sync_serial_sender = sync_serial_sender async def open(self): @@ -249,6 +296,9 @@ def is_open(self): return self._sync_serial_sender.is_open async def get_values(self): + """ + Read current sensor values from serial device and return as a list of ints. + """ response = await asyncio.to_thread(lambda: self._sync_serial_sender.send('v\n')) # Expect current sensor values preceded by a 'v'. # v num1 num2 num3 num4 @@ -258,6 +308,9 @@ async def get_values(self): return [int(x) for x in parts[1:]] async def get_thresholds(self): + """ + Read current threshold values from serial device and return as a list of ints. + """ response = await asyncio.to_thread(lambda: self._sync_serial_sender.send('t\n')) # Expect current thresholds preceded by a 't'. # t num1 num2 num3 num4 @@ -267,6 +320,14 @@ async def get_thresholds(self): return [int(x) for x in parts[1:]] async def update_threshold(self, index, threshold): + """ + Write a single threshold update command. + Read all current threshold values from serial device and return as a list of ints. + + Keyword arguments: + index -- index starting from 0 of the threshold to update + threshold -- new threshold value + """ threshold_cmd = '%d %d\n' % (index, threshold) response = await asyncio.to_thread(lambda: self._sync_serial_sender.send(threshold_cmd)) # Expect updated thresholds preceded by a 't'. @@ -278,7 +339,15 @@ async def update_threshold(self, index, threshold): async def update_thresholds(self, thresholds): """ - Update multiple thresholds. Return the new thresholds after the final update. + Send a series of commands to the serial device to update all thresholds, + one at a time. + Read all current threshold values from serial device after final update + and return as a list of ints. + + Keyword arguments: + thresholds -- list of thresholds as ints to update. The index of the list + maps to the index of the thresholds, so index 0 will update threshold 0 + and so on """ for index, threshold in enumerate(thresholds): new_thresholds = await self.update_threshold(index, threshold) From e7f5ffa7ac877811c4abd2fb08d5f9952bd944c7 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Mon, 14 Feb 2022 00:47:13 -0500 Subject: [PATCH 43/56] Document WebSocketHandler in server.py --- webui/server/server.py | 90 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 10 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index b58545d..d3301e2 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -354,9 +354,17 @@ async def update_thresholds(self, thresholds): return new_thresholds class WebSocketHandler(object): + """ + Handle websocket connections to communicate with the Web UI. + + The design of this class is based on the assumptions that all + connected clients should be kept in sync. Messages received from any + client are placed in the same single queue, and outgoing messages + are sent to every connected client. + """ def __init__(self): # Set when connecting or disconnecting serial device. - self.serial_connected = False + self._serial_connected = False # Queue to pass messages to main Task self._receive_queue = asyncio.Queue(maxsize=1) # Used to coordinate updates to app['websockets'] set @@ -366,16 +374,39 @@ def __init__(self): # Set of open websocket tasks to cancel when the app shuts down. self._websocket_tasks = set() - # Only the task that opens a websocket is allowed to call receive on it, - # so call receive in the handler task and queue messages for other coroutines to read. + @property + def serial_connected(self): + return self._serial_connected + + @serial_connected.setter + def serial_connected(self, serial_connected): + """ + Set to True or False when serial connects or disconnects, so that + websocket requests can return 503 service unavailable if serial + is not connected. + """ + self._serial_connected = serial_connected + async def receive_json(self): + """ + Receive the next available client message from any client. + Messages are filtered to only WSMsgType.TEXT and already + parsed as JSON. + """ return await self._receive_queue.get() - # Call after processing the json from receive_json + # Call after processing the json from receive_json. + # See documentation for ideas on how to use. At the time of this writing, + # there is no code caling _receive_queue.join() but I thought it might come + # in handy later. -Josh + # https://docs.python.org/3/library/asyncio-queue.html#asyncio.Queue.task_done def task_done(self): self._receive_queue.task_done() async def send_json_all(self, msg): + """ + Serialize msg as JSON and wait to send it to every connected client. + """ # Iterate over copy of set in case the set is modified while awaiting a send websockets = self._websockets.copy() for ws in websockets: @@ -383,32 +414,67 @@ async def send_json_all(self, msg): await ws.send_json(msg) async def broadcast_thresholds(self, thresholds): - """Send current thresholds to all connected clients""" + """ + Send current thresholds to all connected clients + + Keyword arguments: + thresholds -- threshold values as list of ints + """ await self.send_json_all(['thresholds', {'thresholds': thresholds}]) async def broadcast_values(self, values): - """Send current sensor values to all connected clients""" + """ + Send current sensor values to all connected clients + + Keyword arguments: + values -- sensor values as a list of ints + """ await self.send_json_all(['values', {'values': values}]) async def broadcast_profiles(self, profiles): - """Send list of profile names to all connected clients""" + """ + Send list of profile names to all connected clients + + Keyword arguments: + profiles -- list of profile names + """ await self.send_json_all(['get_profiles', {'profiles': profiles}]) async def broadcast_cur_profile(self, cur_profile): - """Send name of current profile to all connected clients""" + """ + Send name of current profile to all connected clients + + Keyword arguments: + cur_profile -- current profile name + """ await self.send_json_all(['get_cur_profile', {'cur_profile': cur_profile}]) async def cancel_ws_tasks(self): async with self._websockets_lock: for task in self._websocket_tasks: task.cancel() - + @property def has_clients(self): + """ + Return True if any clients are connected. + """ return len(self._websockets) > 0 - # Pass to router, this coroutine will be run in one task per connection async def handle_ws(self, request): + """ + aiohttp route handling function. Use with router, which will start one + asyncio task per connection using this coroutine. + + Return error 503 without opening websocket if serial is not connected. + + Open websocket and add it to a set of open websockets, used to broadcast + messages from the main task loop (using the `broadcast_*` methods of this + class). + + Receive client messages from this websocket connection in the main task + loop with the `receive_json` method of this class. + """ if not self.serial_connected: return json_response({}, status=503) @@ -421,6 +487,10 @@ async def handle_ws(self, request): self._websocket_tasks.add(this_task) print('Client connected') + # Only the request handling task that opens a websocket is allowed to call + # `receive` on it, so call receive here and queue message data for another + # task to read. + # https://docs.aiohttp.org/en/stable/web_reference.html#aiohttp.web.WebSocketResponse.receive try: while not ws.closed: msg = await ws.receive() From 7bce265214b62ddbc47d32d6cef8fcc42c1919fb Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Mon, 14 Feb 2022 02:03:09 -0500 Subject: [PATCH 44/56] Close websockets properly Call close() instead of canceling websocket tasks. Fix some bugs that prevented this from working propertly before. --- webui/server/server.py | 47 ++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index d3301e2..e29c85e 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -7,7 +7,7 @@ from random import normalvariate import serial -from aiohttp import web, WSMsgType +from aiohttp import web, WSCloseCode, WSMsgType from aiohttp.web import json_response logger = logging.getLogger(__name__) @@ -367,12 +367,9 @@ def __init__(self): self._serial_connected = False # Queue to pass messages to main Task self._receive_queue = asyncio.Queue(maxsize=1) - # Used to coordinate updates to app['websockets'] set - self._websockets_lock = asyncio.Lock() - # Set of open websockets used to broadcast messages to all clients. + # Set of open websockets used to broadcast messages to all clients, + # and to close in case of errors or shutdown. self._websockets = set() - # Set of open websocket tasks to cancel when the app shuts down. - self._websocket_tasks = set() @property def serial_connected(self): @@ -407,7 +404,7 @@ async def send_json_all(self, msg): """ Serialize msg as JSON and wait to send it to every connected client. """ - # Iterate over copy of set in case the set is modified while awaiting a send + # Iterate over copy of set in case the set is modified while awaiting websockets = self._websockets.copy() for ws in websockets: if not ws.closed: @@ -449,10 +446,21 @@ async def broadcast_cur_profile(self, cur_profile): """ await self.send_json_all(['get_cur_profile', {'cur_profile': cur_profile}]) - async def cancel_ws_tasks(self): - async with self._websockets_lock: - for task in self._websocket_tasks: - task.cancel() + async def close_websockets(self, code=WSCloseCode.OK, message=b''): + """ + Close all open websockets. + + code and message arguments are passed to each close() call, see + https://docs.aiohttp.org/en/stable/web_reference.html#aiohttp.web.WebSocketResponse.close + + Keyword arguments: + code -- closing code (default WSCloseCode.OK) + message -- optional payload of close message, str (converted to UTF-8 encoded bytes) or bytes. + """ + # Iterate over copy of set in case the set is modified while awaiting + websockets = self._websockets.copy() + for ws in websockets: + await ws.close(code=code, message=message) @property def has_clients(self): @@ -478,13 +486,10 @@ async def handle_ws(self, request): if not self.serial_connected: return json_response({}, status=503) - this_task = asyncio.current_task() ws = web.WebSocketResponse() await ws.prepare(request) - async with self._websockets_lock: - self._websockets.add(ws) - self._websocket_tasks.add(this_task) + self._websockets.add(ws) print('Client connected') # Only the request handling task that opens a websocket is allowed to call @@ -494,15 +499,13 @@ async def handle_ws(self, request): try: while not ws.closed: msg = await ws.receive() - if msg.type == WSMsgType.CLOSE: + if msg.type == WSMsgType.CLOSING: break elif msg.type == WSMsgType.TEXT: data = msg.json() - await self._receive_queue.put(data) + await self._receive_queue.put(data) finally: - async with self._websockets_lock: - self._websockets.remove(ws) - self._websocket_tasks.remove(this_task) + self._websockets.remove(ws) await ws.close() print('Client disconnected') @@ -632,7 +635,7 @@ async def handle_client_message(data): logger.exception('Serial error: %s', e) websocket_handler.serial_connected = False defaults_handler.set_profile_handler(None) - await websocket_handler.cancel_ws_tasks() + await websocket_handler.close_websockets(code=WSCloseCode.INTERNAL_ERROR, message='Serial error') await asyncio.sleep(3) build_dir = os.path.abspath( @@ -655,7 +658,7 @@ async def on_startup(app): asyncio.create_task(run_main_task_loop(websocket_handler=websocket_handler, serial_handler=serial_handler, defaults_handler=defaults_handler)) async def on_shutdown(app): - await websocket_handler.cancel_ws_tasks() + await websocket_handler.close_websockets(code=WSCloseCode.GOING_AWAY, message='Server shutdown') app = web.Application() From 06d66961e3c0b835a4b2e64954068af9b3d4add2 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Tue, 15 Feb 2022 22:22:58 -0500 Subject: [PATCH 45/56] DefaultsHandler docstrings --- webui/server/server.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/webui/server/server.py b/webui/server/server.py index e29c85e..76f5a1c 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -510,13 +510,25 @@ async def handle_ws(self, request): print('Client disconnected') class DefaultsHandler(object): + """ + Handle the /defaults route. + """ def __init__(self): + # Don't write to the profile handler from this class. + # Only the main task loop should be be updating it. self._profile_handler = None def set_profile_handler(self, profile_handler): + """ + Set a ProfileHandler instance here, or set to None to clear it. + """ self._profile_handler = profile_handler async def handle_defaults(self, request): + """ + Return an initial set of values for the WebUI to use for setup before + connecting to the websocket. + """ if self._profile_handler: return json_response({ 'profiles': self._profile_handler.GetProfileNames(), From 27e6665c1cc565c74cfa760bae9bf4782623e7de Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Tue, 15 Feb 2022 22:40:50 -0500 Subject: [PATCH 46/56] Clean up server.py main function --- webui/server/server.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 76f5a1c..d62bd17 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -650,30 +650,31 @@ async def handle_client_message(data): await websocket_handler.close_websockets(code=WSCloseCode.INTERNAL_ERROR, message='Serial error') await asyncio.sleep(3) -build_dir = os.path.abspath( - os.path.join(os.path.dirname(__file__), '..', 'build') -) - -async def get_index(request): - return web.FileResponse(os.path.join(build_dir, 'index.html')) - def main(): defaults_handler = DefaultsHandler() websocket_handler = WebSocketHandler() + build_dir = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', 'build') + ) + if NO_SERIAL: serial_handler = FakeSerialHandler(num_sensors=4) else: serial_handler = SerialHandler(SyncSerialSender(port=SERIAL_PORT, timeout=0.05)) async def on_startup(app): - asyncio.create_task(run_main_task_loop(websocket_handler=websocket_handler, serial_handler=serial_handler, defaults_handler=defaults_handler)) + asyncio.create_task(run_main_task_loop(websocket_handler=websocket_handler, + serial_handler=serial_handler, + defaults_handler=defaults_handler)) async def on_shutdown(app): await websocket_handler.close_websockets(code=WSCloseCode.GOING_AWAY, message='Server shutdown') - app = web.Application() + async def get_index(request): + return web.FileResponse(os.path.join(build_dir, 'index.html')) + app = web.Application() app.add_routes([ web.get('/defaults', defaults_handler.handle_defaults), web.get('/ws', websocket_handler.handle_ws), From 8aac01e1a4a1ae2cd0b5cc2c201b563987bb849a Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Tue, 15 Feb 2022 22:50:15 -0500 Subject: [PATCH 47/56] Add SERVE_STATIC_FRONTEND_FILES config constant Check the new flag instead of NO_SERIAL when deciding whether to serve the files from the build directory --- webui/server/server.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index d62bd17..396328a 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -20,6 +20,10 @@ # emulate the serial device instead of actually connecting to one. NO_SERIAL = False +# Serve the index and assets for the Web UI. +# If False, only serve the websocket and JSON endpoints. +SERVE_STATIC_FRONTEND_FILES = True + class CommandFormatError(Exception): """Serial responded but command was not in the expected format.""" @@ -355,7 +359,7 @@ async def update_thresholds(self, thresholds): class WebSocketHandler(object): """ - Handle websocket connections to communicate with the Web UI. + Handle websocket connections to communicate with the WebUI. The design of this class is based on the assumptions that all connected clients should be kept in sync. Messages received from any @@ -679,7 +683,7 @@ async def get_index(request): web.get('/defaults', defaults_handler.handle_defaults), web.get('/ws', websocket_handler.handle_ws), ]) - if not NO_SERIAL: + if SERVE_STATIC_FRONTEND_FILES: app.add_routes([ web.get('/', get_index), web.get('/plot', get_index), From ccf4c83ee493404b5c15fc152d073050f1d7e5cc Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Tue, 15 Feb 2022 23:01:17 -0500 Subject: [PATCH 48/56] Add main and run_main_task_loop docstrings --- webui/server/server.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/webui/server/server.py b/webui/server/server.py index 396328a..808095d 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -543,6 +543,22 @@ async def handle_defaults(self, request): return json_response({}, status=503) async def run_main_task_loop(websocket_handler, serial_handler, defaults_handler): + """ + Connect to a serial device and poll it for sensor values. + Handle incoming commands from WebUI clients. + + Disconnect clients and retry serial connection in case of serial errors. + + Keyword arguments: + websocket_handler -- Should be the same instance that the aiohttp server is using + handle requests. Used for receiving messages from any client and broadcasting + messages to all clients. + serial_handler -- Preconfigured with port and timeout, not expected to be open + initially. + defaults_handler -- Should be the same instance that the aiohttp server is using + to handle requests. The main task loop creates a ProfileHandler instance and + shares it with defaults_handler when it's ready. + """ profile_handler = None async def update_threshold(values, index): @@ -655,6 +671,7 @@ async def handle_client_message(data): await asyncio.sleep(3) def main(): + """Set up and run the http server.""" defaults_handler = DefaultsHandler() websocket_handler = WebSocketHandler() From 5acaced2eeb6e2cec6236a032f8c0a3f32b54b06 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Wed, 16 Feb 2022 00:27:13 -0500 Subject: [PATCH 49/56] Rename some CamelCase to snake_case in server.py --- webui/server/server.py | 100 ++++++++++++++++++++--------------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 808095d..cf450fa 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -51,12 +51,12 @@ def __init__(self, num_sensors, filename='profiles.txt'): # Have a default no-name profile we can use in case there are no profiles. self._profiles[''] = [0] * self._num_sensors - def _AssertThresholdsLength(self, thresholds): + def _assert_thresholds_length(self, thresholds): """Raise error if thresholds list is not the expected length.""" if not len(thresholds) == self._num_sensors: raise ValueError('Expected {} threshold values, got {}'.format(self._num_sensors, thresholds)) - def _Save(self): + def _save(self): """ Save profiles to file. The empty-name '' profile is always skipped. """ @@ -65,7 +65,7 @@ def _Save(self): if name: f.write(name + ' ' + ' '.join(map(str, thresholds)) + '\n') - def Load(self): + def load(self): """ Load profiles from file if it exists, and change the to the first profile found. If no profiles are found, do not change the current profile. @@ -80,13 +80,13 @@ def Load(self): num_profiles += 1 # Change to the first profile found. if num_profiles == 1: - self.ChangeProfile(parts[0]) + self.change_profile(parts[0]) - def GetCurThresholds(self): + def get_cur_thresholds(self): """Return thresholds of current profile.""" return self._profiles[self._cur_profile] - def UpdateThreshold(self, index, value): + def update_threshold(self, index, value): """ Update one threshold in the current profile, and save profiles to file. @@ -95,9 +95,9 @@ def UpdateThreshold(self, index, value): value -- new threshold value """ self._profiles[self._cur_profile][index] = value - self._Save() + self._save() - def UpdateThresholds(self, values): + def update_thresholds(self, values): """ Update all thresholds in the current profile, and save profiles to file. The number of values must match the configured num_panels. @@ -105,11 +105,11 @@ def UpdateThresholds(self, values): Keyword arguments: thresholds -- list of new threshold values """ - self._AssertThresholdsLength(values) + self._assert_thresholds_length(values) self._profiles[self._cur_profile] = values.copy() - self._Save() + self._save() - def ChangeProfile(self, profile_name): + def change_profile(self, profile_name): """ Change to a profile. If there is no profile by that name, remain on the current profile. @@ -119,14 +119,14 @@ def ChangeProfile(self, profile_name): else: print("Ignoring ChangeProfile, ", profile_name, " not in ", self._profiles) - def GetProfileNames(self): + def get_profile_names(self): """ Return list of all profile names. Does not include the empty-name '' profile. """ return [name for name in self._profiles.keys() if name] - def AddProfile(self, profile_name, thresholds): + def add_profile(self, profile_name, thresholds): """ If the current profile is the empty-name '' profile, reset thresholds to defaults. Add a profile, change to it, and save profiles to file. @@ -135,14 +135,14 @@ def AddProfile(self, profile_name, thresholds): profile_name -- the name of the new profile thresholds -- list of threshold values for the new profile """ - self._AssertThresholdsLength(thresholds) + self._assert_thresholds_length(thresholds) self._profiles[profile_name] = thresholds if self._cur_profile == '': self._profiles[''] = [0] * self._num_sensors - self.ChangeProfile(profile_name) - self._Save() + self.change_profile(profile_name) + self._save() - def RemoveProfile(self, profile_name): + def remove_profile(self, profile_name): """ Delete a profile and save profiles to file. Change to empty-name '' profile if deleted profile was the current profile. @@ -153,10 +153,10 @@ def RemoveProfile(self, profile_name): return del self._profiles[profile_name] if profile_name == self._cur_profile: - self.ChangeProfile('') - self._Save() + self.change_profile('') + self._save() - def GetCurrentProfile(self): + def get_current_profile(self): """Return current profile name.""" return self._cur_profile @@ -535,9 +535,9 @@ async def handle_defaults(self, request): """ if self._profile_handler: return json_response({ - 'profiles': self._profile_handler.GetProfileNames(), - 'cur_profile': self._profile_handler.GetCurrentProfile(), - 'thresholds': self._profile_handler.GetCurThresholds() + 'profiles': self._profile_handler.get_profile_names(), + 'cur_profile': self._profile_handler.get_current_profile(), + 'thresholds': self._profile_handler.get_cur_thresholds() }) else: return json_response({}, status=503) @@ -563,45 +563,45 @@ async def run_main_task_loop(websocket_handler, serial_handler, defaults_handler async def update_threshold(values, index): thresholds = await serial_handler.update_threshold(index, values[index]) - profile_handler.UpdateThresholds(thresholds) - await websocket_handler.broadcast_thresholds(profile_handler.GetCurThresholds()) + profile_handler.update_thresholds(thresholds) + await websocket_handler.broadcast_thresholds(profile_handler.get_cur_thresholds()) print('Profile is "{}". Thresholds are: {}'.format( - profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) + profile_handler.get_current_profile(), str(profile_handler.get_cur_thresholds()))) async def update_thresholds(values): thresholds = await serial_handler.update_thresholds(values) - profile_handler.UpdateThresholds(thresholds) - await websocket_handler.broadcast_thresholds(profile_handler.GetCurThresholds()) + profile_handler.update_thresholds(thresholds) + await websocket_handler.broadcast_thresholds(profile_handler.get_cur_thresholds()) print('Profile is "{}". Thresholds are: {}'.format( - profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) + profile_handler.get_current_profile(), str(profile_handler.get_cur_thresholds()))) async def add_profile(profile_name, thresholds): - profile_handler.AddProfile(profile_name, thresholds) + profile_handler.add_profile(profile_name, thresholds) # When we add a profile, we are using the currently loaded thresholds so we # don't need to explicitly apply anything. - await websocket_handler.broadcast_profiles(profile_handler.GetProfileNames()) - await websocket_handler.broadcast_cur_profile(profile_handler.GetCurrentProfile()) + await websocket_handler.broadcast_profiles(profile_handler.get_profile_names()) + await websocket_handler.broadcast_cur_profile(profile_handler.get_current_profile()) print('Changed to new profile "{}". Thresholds are: {}'.format( - profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) + profile_handler.get_current_profile(), str(profile_handler.get_cur_thresholds()))) async def remove_profile(profile_name): - profile_handler.RemoveProfile(profile_name) + profile_handler.remove_profile(profile_name) # Need to apply the thresholds of the profile we've fallen back to. - thresholds = profile_handler.GetCurThresholds() + thresholds = profile_handler.get_cur_thresholds() await update_thresholds(thresholds) - await websocket_handler.broadcast_profiles(profile_handler.GetProfileNames()) - await websocket_handler.broadcast_cur_profile(profile_handler.GetCurrentProfile()) + await websocket_handler.broadcast_profiles(profile_handler.get_profile_names()) + await websocket_handler.broadcast_cur_profile(profile_handler.get_current_profile()) print('Removed profile "{}". Profile is "{}". Thresholds are: {}'.format( - profile_name, profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) + profile_name, profile_handler.get_current_profile(), str(profile_handler.get_cur_thresholds()))) async def change_profile(profile_name): - profile_handler.ChangeProfile(profile_name) + profile_handler.change_profile(profile_name) # Need to apply the thresholds of the profile we've changed to. - thresholds = profile_handler.GetCurThresholds() + thresholds = profile_handler.get_cur_thresholds() await update_thresholds(thresholds) - await websocket_handler.broadcast_cur_profile(profile_handler.GetCurrentProfile()) + await websocket_handler.broadcast_cur_profile(profile_handler.get_current_profile()) print('Changed to profile "{}". Thresholds are: {}'.format( - profile_handler.GetCurrentProfile(), str(profile_handler.GetCurThresholds()))) + profile_handler.get_current_profile(), str(profile_handler.get_cur_thresholds()))) async def handle_client_message(data): action = data[0] @@ -628,24 +628,24 @@ async def handle_client_message(data): profile_handler = ProfileHandler(num_sensors=len(thresholds)) # Load saved profiles - profile_handler.Load() - print('Found Profiles: ' + str(list(profile_handler.GetProfileNames()))) + profile_handler.load() + print('Found Profiles: ' + str(list(profile_handler.get_profile_names()))) # Send current thresholds from loaded profile, then write back from MCU to profiles. - thresholds = profile_handler.GetCurThresholds() + thresholds = profile_handler.get_cur_thresholds() thresholds = await serial_handler.update_thresholds(thresholds) - profile_handler.UpdateThresholds(thresholds) + profile_handler.update_thresholds(thresholds) print('Profile is "{}". Thresholds are: {}'.format( - profile_handler.GetCurrentProfile(), profile_handler.GetCurThresholds())) + profile_handler.get_current_profile(), profile_handler.get_cur_thresholds())) # Handle GET /defaults using new profile_handler defaults_handler.set_profile_handler(profile_handler) # Minimum delay in seconds to wait betwen getting current sensor values - POLL_VALUES_WAIT_SECONDS = 1.0 / 100 + poll_values_wait_seconds = 1.0 / 100 # Poll sensor values and handle client message - poll_values_task = asyncio.create_task(asyncio.sleep(POLL_VALUES_WAIT_SECONDS)) + poll_values_task = asyncio.create_task(asyncio.sleep(poll_values_wait_seconds)) receive_json_task = asyncio.create_task(websocket_handler.receive_json()) while True: done, pending = await asyncio.wait([poll_values_task, receive_json_task], return_when=asyncio.FIRST_COMPLETED) @@ -654,7 +654,7 @@ async def handle_client_message(data): if websocket_handler.has_clients: values = await serial_handler.get_values() await websocket_handler.broadcast_values(values) - poll_values_task = asyncio.create_task(asyncio.sleep(POLL_VALUES_WAIT_SECONDS)) + poll_values_task = asyncio.create_task(asyncio.sleep(poll_values_wait_seconds)) if task == receive_json_task: data = await task await handle_client_message(data) From d0c91b3d1f2d2175a8e65643a9c75ba991a0ade4 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Wed, 16 Feb 2022 00:34:20 -0500 Subject: [PATCH 50/56] Fix unused argument/variable warnings --- webui/server/server.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/webui/server/server.py b/webui/server/server.py index cf450fa..843df2f 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -487,6 +487,7 @@ async def handle_ws(self, request): Receive client messages from this websocket connection in the main task loop with the `receive_json` method of this class. """ + if not self.serial_connected: return json_response({}, status=503) @@ -533,6 +534,7 @@ async def handle_defaults(self, request): Return an initial set of values for the WebUI to use for setup before connecting to the websocket. """ + del request # unused if self._profile_handler: return json_response({ 'profiles': self._profile_handler.get_profile_names(), @@ -648,7 +650,7 @@ async def handle_client_message(data): poll_values_task = asyncio.create_task(asyncio.sleep(poll_values_wait_seconds)) receive_json_task = asyncio.create_task(websocket_handler.receive_json()) while True: - done, pending = await asyncio.wait([poll_values_task, receive_json_task], return_when=asyncio.FIRST_COMPLETED) + done, _ = await asyncio.wait([poll_values_task, receive_json_task], return_when=asyncio.FIRST_COMPLETED) for task in done: if task == poll_values_task: if websocket_handler.has_clients: @@ -685,14 +687,17 @@ def main(): serial_handler = SerialHandler(SyncSerialSender(port=SERIAL_PORT, timeout=0.05)) async def on_startup(app): + del app # unused asyncio.create_task(run_main_task_loop(websocket_handler=websocket_handler, serial_handler=serial_handler, defaults_handler=defaults_handler)) async def on_shutdown(app): + del app # unused await websocket_handler.close_websockets(code=WSCloseCode.GOING_AWAY, message='Server shutdown') async def get_index(request): + del request # unused return web.FileResponse(os.path.join(build_dir, 'index.html')) app = web.Application() From 92066083ac3af89d90a0d4f27d6bd3e8cbf460fb Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Wed, 16 Feb 2022 00:53:19 -0500 Subject: [PATCH 51/56] Use format strings in server.py --- webui/server/server.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 843df2f..35cae4e 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -54,7 +54,7 @@ def __init__(self, num_sensors, filename='profiles.txt'): def _assert_thresholds_length(self, thresholds): """Raise error if thresholds list is not the expected length.""" if not len(thresholds) == self._num_sensors: - raise ValueError('Expected {} threshold values, got {}'.format(self._num_sensors, thresholds)) + raise ValueError(f'Expected {self._num_sensors} threshold values, got {thresholds}') def _save(self): """ @@ -265,7 +265,7 @@ def send(self, command): # instance's configured timeout, it will return whatever it has # read so far. PySerial does not throw an exception, but we will. if not line.endswith('\n'): - raise SerialReadTimeoutError('Timeout reading response to command. {} {}'.format(command, line)) + raise SerialReadTimeoutError(f'Timeout reading response to command. {command} {line}') return line.strip() @@ -308,7 +308,7 @@ async def get_values(self): # v num1 num2 num3 num4 parts = response.split() if parts[0] != 'v': - raise CommandFormatError('Expected values in response, got "{}"' % (response)) + raise CommandFormatError(f'Expected values in response, got "{response}"') return [int(x) for x in parts[1:]] async def get_thresholds(self): @@ -320,7 +320,7 @@ async def get_thresholds(self): # t num1 num2 num3 num4 parts = response.split() if parts[0] != 't': - raise CommandFormatError('Expected thresholds in response, got "{}"' % (response)) + raise CommandFormatError(f'Expected thresholds in response, got "{response}"') return [int(x) for x in parts[1:]] async def update_threshold(self, index, threshold): @@ -332,13 +332,13 @@ async def update_threshold(self, index, threshold): index -- index starting from 0 of the threshold to update threshold -- new threshold value """ - threshold_cmd = '%d %d\n' % (index, threshold) + threshold_cmd = f'{index} {threshold}\n' response = await asyncio.to_thread(lambda: self._sync_serial_sender.send(threshold_cmd)) # Expect updated thresholds preceded by a 't'. # t num1 num2 num3 num4 parts = response.split() if parts[0] != 't': - raise CommandFormatError('Expected thresholds in response, got "{}"' % (response)) + raise CommandFormatError(f'Expected thresholds in response, got "{response}"') return [int(x) for x in parts[1:]] async def update_thresholds(self, thresholds): @@ -535,6 +535,11 @@ async def handle_defaults(self, request): connecting to the websocket. """ del request # unused + print({ + 'profiles': self._profile_handler.get_profile_names(), + 'cur_profile': self._profile_handler.get_profile_names(), + 'thresholds': self._profile_handler.get_profile_names() + }) if self._profile_handler: return json_response({ 'profiles': self._profile_handler.get_profile_names(), @@ -567,15 +572,13 @@ async def update_threshold(values, index): thresholds = await serial_handler.update_threshold(index, values[index]) profile_handler.update_thresholds(thresholds) await websocket_handler.broadcast_thresholds(profile_handler.get_cur_thresholds()) - print('Profile is "{}". Thresholds are: {}'.format( - profile_handler.get_current_profile(), str(profile_handler.get_cur_thresholds()))) + print(f'Profile is "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}') async def update_thresholds(values): thresholds = await serial_handler.update_thresholds(values) profile_handler.update_thresholds(thresholds) await websocket_handler.broadcast_thresholds(profile_handler.get_cur_thresholds()) - print('Profile is "{}". Thresholds are: {}'.format( - profile_handler.get_current_profile(), str(profile_handler.get_cur_thresholds()))) + print(f'Profile is "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}') async def add_profile(profile_name, thresholds): profile_handler.add_profile(profile_name, thresholds) @@ -583,8 +586,7 @@ async def add_profile(profile_name, thresholds): # don't need to explicitly apply anything. await websocket_handler.broadcast_profiles(profile_handler.get_profile_names()) await websocket_handler.broadcast_cur_profile(profile_handler.get_current_profile()) - print('Changed to new profile "{}". Thresholds are: {}'.format( - profile_handler.get_current_profile(), str(profile_handler.get_cur_thresholds()))) + print(f'Changed to new profile "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}') async def remove_profile(profile_name): profile_handler.remove_profile(profile_name) @@ -593,8 +595,7 @@ async def remove_profile(profile_name): await update_thresholds(thresholds) await websocket_handler.broadcast_profiles(profile_handler.get_profile_names()) await websocket_handler.broadcast_cur_profile(profile_handler.get_current_profile()) - print('Removed profile "{}". Profile is "{}". Thresholds are: {}'.format( - profile_name, profile_handler.get_current_profile(), str(profile_handler.get_cur_thresholds()))) + print(f'Removed profile "{profile_name}". Profile is "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}') async def change_profile(profile_name): profile_handler.change_profile(profile_name) @@ -602,8 +603,7 @@ async def change_profile(profile_name): thresholds = profile_handler.get_cur_thresholds() await update_thresholds(thresholds) await websocket_handler.broadcast_cur_profile(profile_handler.get_current_profile()) - print('Changed to profile "{}". Thresholds are: {}'.format( - profile_handler.get_current_profile(), str(profile_handler.get_cur_thresholds()))) + print(f'Changed to profile "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}') async def handle_client_message(data): action = data[0] @@ -637,8 +637,7 @@ async def handle_client_message(data): thresholds = profile_handler.get_cur_thresholds() thresholds = await serial_handler.update_thresholds(thresholds) profile_handler.update_thresholds(thresholds) - print('Profile is "{}". Thresholds are: {}'.format( - profile_handler.get_current_profile(), profile_handler.get_cur_thresholds())) + print(f'Profile is "{profile_handler.get_current_profile()}". Thresholds are: {profile_handler.get_cur_thresholds()}') # Handle GET /defaults using new profile_handler defaults_handler.set_profile_handler(profile_handler) From 071cf225fd8b2eede95d6333617487c1cf827d3c Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Wed, 16 Feb 2022 01:03:13 -0500 Subject: [PATCH 52/56] Specify file encodings in server.py --- webui/server/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index 35cae4e..e0c7973 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -60,7 +60,7 @@ def _save(self): """ Save profiles to file. The empty-name '' profile is always skipped. """ - with open(self._filename, 'w') as f: + with open(self._filename, 'w', encoding="utf-8") as f: for name, thresholds in self._profiles.items(): if name: f.write(name + ' ' + ' '.join(map(str, thresholds)) + '\n') @@ -72,7 +72,7 @@ def load(self): """ num_profiles = 0 if os.path.exists(self._filename): - with open(self._filename, 'r') as f: + with open(self._filename, 'r', encoding="utf-8") as f: for line in f: parts = line.split() if len(parts) == (self._num_sensors + 1): From da6b6b2ea1f07d839f96c8ff849ad741ec5b183e Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Wed, 16 Feb 2022 01:06:52 -0500 Subject: [PATCH 53/56] Use single quoted strings in server.py --- webui/server/server.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index e0c7973..eaaeaad 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) # Edit this to match the serial port name shown in Arduino IDE -SERIAL_PORT = "/dev/ttyACM0" +SERIAL_PORT = '/dev/ttyACM0' HTTP_PORT = 5000 # Used for developmental purposes. Set this to true when you just want to @@ -60,7 +60,7 @@ def _save(self): """ Save profiles to file. The empty-name '' profile is always skipped. """ - with open(self._filename, 'w', encoding="utf-8") as f: + with open(self._filename, 'w', encoding='utf-8') as f: for name, thresholds in self._profiles.items(): if name: f.write(name + ' ' + ' '.join(map(str, thresholds)) + '\n') @@ -72,7 +72,7 @@ def load(self): """ num_profiles = 0 if os.path.exists(self._filename): - with open(self._filename, 'r', encoding="utf-8") as f: + with open(self._filename, 'r', encoding='utf-8') as f: for line in f: parts = line.split() if len(parts) == (self._num_sensors + 1): @@ -117,7 +117,7 @@ def change_profile(self, profile_name): if profile_name in self._profiles: self._cur_profile = profile_name else: - print("Ignoring ChangeProfile, ", profile_name, " not in ", self._profiles) + print('Ignoring ChangeProfile, ', profile_name, ' not in ', self._profiles) def get_profile_names(self): """ @@ -149,7 +149,7 @@ def remove_profile(self, profile_name): Trying to delete an unknown profile will print a warning and do nothing. """ if not profile_name in self._profiles: - print("No profile named ", profile_name, " to delete in ", self._profiles) + print('No profile named ', profile_name, ' to delete in ', self._profiles) return del self._profiles[profile_name] if profile_name == self._cur_profile: @@ -238,7 +238,7 @@ def close(self): @property def is_open(self): - "Return True if serial port is open, false otherwise." + """Return True if serial port is open, false otherwise.""" return self._ser and self._ser.is_open def send(self, command): From 5c3527d3ef6be39d8f5011c64ef437e9308de978 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Wed, 16 Feb 2022 01:09:06 -0500 Subject: [PATCH 54/56] Autoformat server.py yapf --style='{based_on_style: google, indent_width: 2}' -i server/server.py --- webui/server/server.py | 181 +++++++++++++++++++++++++++-------------- 1 file changed, 118 insertions(+), 63 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index eaaeaad..d6ae0c9 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -24,20 +24,24 @@ # If False, only serve the websocket and JSON endpoints. SERVE_STATIC_FRONTEND_FILES = True + class CommandFormatError(Exception): """Serial responded but command was not in the expected format.""" + class SerialReadTimeoutError(Exception): """ Serial response did not end in a newline, presumably because read operation timed out before receiving one. """ + class ProfileHandler(object): """ Track a list of profiles and which is the "current" one. Handle saving and loading profiles from a text file. """ + def __init__(self, num_sensors, filename='profiles.txt'): """ Keyword arguments: @@ -54,7 +58,8 @@ def __init__(self, num_sensors, filename='profiles.txt'): def _assert_thresholds_length(self, thresholds): """Raise error if thresholds list is not the expected length.""" if not len(thresholds) == self._num_sensors: - raise ValueError(f'Expected {self._num_sensors} threshold values, got {thresholds}') + raise ValueError( + f'Expected {self._num_sensors} threshold values, got {thresholds}') def _save(self): """ @@ -117,7 +122,8 @@ def change_profile(self, profile_name): if profile_name in self._profiles: self._cur_profile = profile_name else: - print('Ignoring ChangeProfile, ', profile_name, ' not in ', self._profiles) + print('Ignoring ChangeProfile, ', profile_name, ' not in ', + self._profiles) def get_profile_names(self): """ @@ -160,6 +166,7 @@ def get_current_profile(self): """Return current profile name.""" return self._cur_profile + class FakeSerialHandler(object): """ Use in place of SerialHandler to test without a real serial device. @@ -167,6 +174,7 @@ class FakeSerialHandler(object): Returns random sensor values on each read. The previous sensor values influence the next read so the graph isn't too jumpy. """ + def __init__(self, num_sensors): """ Keyword arguments: @@ -181,17 +189,20 @@ async def open(self): self._is_open = True def close(self): - self._is_open = False + self._is_open = False @property def is_open(self): return self._is_open async def get_values(self): - offsets = [int(normalvariate(0, self._num_sensors + 1)) for _ in range(self._num_sensors)] + offsets = [ + int(normalvariate(0, self._num_sensors + 1)) + for _ in range(self._num_sensors) + ] self._sensor_values = [ - max(0, min(self._sensor_values[i] + offsets[i], 1023)) - for i in range(self._num_sensors) + max(0, min(self._sensor_values[i] + offsets[i], 1023)) + for i in range(self._num_sensors) ] return self._sensor_values.copy() @@ -199,18 +210,20 @@ async def get_thresholds(self): return self._sensor_values.copy() async def update_threshold(self, index, threshold): - self._thresholds[index] = threshold - return self._thresholds.copy() - + self._thresholds[index] = threshold + return self._thresholds.copy() + async def update_thresholds(self, thresholds): for i, threshold in enumerate(thresholds): self._thresholds[i] = threshold return self._thresholds.copy() + class SyncSerialSender(object): """ Send and receive serial commands one line at at time. """ + def __init__(self, port, timeout=1.0): """ port -- string, the path/name of the serial object to open @@ -235,7 +248,7 @@ def close(self): if self._ser and not self._ser.closed: self._ser.close() self._ser = None - + @property def is_open(self): """Return True if serial port is open, false otherwise.""" @@ -265,10 +278,12 @@ def send(self, command): # instance's configured timeout, it will return whatever it has # read so far. PySerial does not throw an exception, but we will. if not line.endswith('\n'): - raise SerialReadTimeoutError(f'Timeout reading response to command. {command} {line}') + raise SerialReadTimeoutError( + f'Timeout reading response to command. {command} {line}') return line.strip() + class SerialHandler(object): """ Handle communication with the serial device. @@ -281,6 +296,7 @@ class SerialHandler(object): There is only one underlying hardware device, so any command needs to wait for the previous command and response to be processed. """ + def __init__(self, sync_serial_sender): """ Keyword arguments: @@ -291,10 +307,10 @@ def __init__(self, sync_serial_sender): async def open(self): self._sync_serial_sender.open() - + def close(self): self._sync_serial_sender.close() - + @property def is_open(self): return self._sync_serial_sender.is_open @@ -303,7 +319,8 @@ async def get_values(self): """ Read current sensor values from serial device and return as a list of ints. """ - response = await asyncio.to_thread(lambda: self._sync_serial_sender.send('v\n')) + response = await asyncio.to_thread( + lambda: self._sync_serial_sender.send('v\n')) # Expect current sensor values preceded by a 'v'. # v num1 num2 num3 num4 parts = response.split() @@ -315,13 +332,15 @@ async def get_thresholds(self): """ Read current threshold values from serial device and return as a list of ints. """ - response = await asyncio.to_thread(lambda: self._sync_serial_sender.send('t\n')) + response = await asyncio.to_thread( + lambda: self._sync_serial_sender.send('t\n')) # Expect current thresholds preceded by a 't'. # t num1 num2 num3 num4 parts = response.split() if parts[0] != 't': - raise CommandFormatError(f'Expected thresholds in response, got "{response}"') - return [int(x) for x in parts[1:]] + raise CommandFormatError( + f'Expected thresholds in response, got "{response}"') + return [int(x) for x in parts[1:]] async def update_threshold(self, index, threshold): """ @@ -333,14 +352,16 @@ async def update_threshold(self, index, threshold): threshold -- new threshold value """ threshold_cmd = f'{index} {threshold}\n' - response = await asyncio.to_thread(lambda: self._sync_serial_sender.send(threshold_cmd)) + response = await asyncio.to_thread( + lambda: self._sync_serial_sender.send(threshold_cmd)) # Expect updated thresholds preceded by a 't'. # t num1 num2 num3 num4 parts = response.split() if parts[0] != 't': - raise CommandFormatError(f'Expected thresholds in response, got "{response}"') + raise CommandFormatError( + f'Expected thresholds in response, got "{response}"') return [int(x) for x in parts[1:]] - + async def update_thresholds(self, thresholds): """ Send a series of commands to the serial device to update all thresholds, @@ -357,6 +378,7 @@ async def update_thresholds(self, thresholds): new_thresholds = await self.update_threshold(index, threshold) return new_thresholds + class WebSocketHandler(object): """ Handle websocket connections to communicate with the WebUI. @@ -366,6 +388,7 @@ class WebSocketHandler(object): client are placed in the same single queue, and outgoing messages are sent to every connected client. """ + def __init__(self): # Set when connecting or disconnecting serial device. self._serial_connected = False @@ -378,7 +401,7 @@ def __init__(self): @property def serial_connected(self): return self._serial_connected - + @serial_connected.setter def serial_connected(self, serial_connected): """ @@ -413,7 +436,7 @@ async def send_json_all(self, msg): for ws in websockets: if not ws.closed: await ws.send_json(msg) - + async def broadcast_thresholds(self, thresholds): """ Send current thresholds to all connected clients @@ -422,7 +445,7 @@ async def broadcast_thresholds(self, thresholds): thresholds -- threshold values as list of ints """ await self.send_json_all(['thresholds', {'thresholds': thresholds}]) - + async def broadcast_values(self, values): """ Send current sensor values to all connected clients @@ -514,10 +537,12 @@ async def handle_ws(self, request): await ws.close() print('Client disconnected') + class DefaultsHandler(object): """ Handle the /defaults route. """ + def __init__(self): # Don't write to the profile handler from this class. # Only the main task loop should be be updating it. @@ -534,22 +559,24 @@ async def handle_defaults(self, request): Return an initial set of values for the WebUI to use for setup before connecting to the websocket. """ - del request # unused + del request # unused print({ 'profiles': self._profile_handler.get_profile_names(), 'cur_profile': self._profile_handler.get_profile_names(), 'thresholds': self._profile_handler.get_profile_names() - }) + }) if self._profile_handler: return json_response({ - 'profiles': self._profile_handler.get_profile_names(), - 'cur_profile': self._profile_handler.get_current_profile(), - 'thresholds': self._profile_handler.get_cur_thresholds() + 'profiles': self._profile_handler.get_profile_names(), + 'cur_profile': self._profile_handler.get_current_profile(), + 'thresholds': self._profile_handler.get_cur_thresholds() }) else: return json_response({}, status=503) -async def run_main_task_loop(websocket_handler, serial_handler, defaults_handler): + +async def run_main_task_loop(websocket_handler, serial_handler, + defaults_handler): """ Connect to a serial device and poll it for sensor values. Handle incoming commands from WebUI clients. @@ -571,39 +598,56 @@ async def run_main_task_loop(websocket_handler, serial_handler, defaults_handler async def update_threshold(values, index): thresholds = await serial_handler.update_threshold(index, values[index]) profile_handler.update_thresholds(thresholds) - await websocket_handler.broadcast_thresholds(profile_handler.get_cur_thresholds()) - print(f'Profile is "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}') + await websocket_handler.broadcast_thresholds( + profile_handler.get_cur_thresholds()) + print( + f'Profile is "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}' + ) async def update_thresholds(values): thresholds = await serial_handler.update_thresholds(values) profile_handler.update_thresholds(thresholds) - await websocket_handler.broadcast_thresholds(profile_handler.get_cur_thresholds()) - print(f'Profile is "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}') + await websocket_handler.broadcast_thresholds( + profile_handler.get_cur_thresholds()) + print( + f'Profile is "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}' + ) async def add_profile(profile_name, thresholds): profile_handler.add_profile(profile_name, thresholds) # When we add a profile, we are using the currently loaded thresholds so we # don't need to explicitly apply anything. - await websocket_handler.broadcast_profiles(profile_handler.get_profile_names()) - await websocket_handler.broadcast_cur_profile(profile_handler.get_current_profile()) - print(f'Changed to new profile "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}') + await websocket_handler.broadcast_profiles( + profile_handler.get_profile_names()) + await websocket_handler.broadcast_cur_profile( + profile_handler.get_current_profile()) + print( + f'Changed to new profile "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}' + ) async def remove_profile(profile_name): profile_handler.remove_profile(profile_name) # Need to apply the thresholds of the profile we've fallen back to. thresholds = profile_handler.get_cur_thresholds() await update_thresholds(thresholds) - await websocket_handler.broadcast_profiles(profile_handler.get_profile_names()) - await websocket_handler.broadcast_cur_profile(profile_handler.get_current_profile()) - print(f'Removed profile "{profile_name}". Profile is "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}') + await websocket_handler.broadcast_profiles( + profile_handler.get_profile_names()) + await websocket_handler.broadcast_cur_profile( + profile_handler.get_current_profile()) + print( + f'Removed profile "{profile_name}". Profile is "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}' + ) async def change_profile(profile_name): profile_handler.change_profile(profile_name) # Need to apply the thresholds of the profile we've changed to. thresholds = profile_handler.get_cur_thresholds() await update_thresholds(thresholds) - await websocket_handler.broadcast_cur_profile(profile_handler.get_current_profile()) - print(f'Changed to profile "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}') + await websocket_handler.broadcast_cur_profile( + profile_handler.get_current_profile()) + print( + f'Changed to profile "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}' + ) async def handle_client_message(data): action = data[0] @@ -637,30 +681,36 @@ async def handle_client_message(data): thresholds = profile_handler.get_cur_thresholds() thresholds = await serial_handler.update_thresholds(thresholds) profile_handler.update_thresholds(thresholds) - print(f'Profile is "{profile_handler.get_current_profile()}". Thresholds are: {profile_handler.get_cur_thresholds()}') + print( + f'Profile is "{profile_handler.get_current_profile()}". Thresholds are: {profile_handler.get_cur_thresholds()}' + ) # Handle GET /defaults using new profile_handler defaults_handler.set_profile_handler(profile_handler) - + # Minimum delay in seconds to wait betwen getting current sensor values poll_values_wait_seconds = 1.0 / 100 # Poll sensor values and handle client message - poll_values_task = asyncio.create_task(asyncio.sleep(poll_values_wait_seconds)) + poll_values_task = asyncio.create_task( + asyncio.sleep(poll_values_wait_seconds)) receive_json_task = asyncio.create_task(websocket_handler.receive_json()) while True: - done, _ = await asyncio.wait([poll_values_task, receive_json_task], return_when=asyncio.FIRST_COMPLETED) + done, _ = await asyncio.wait([poll_values_task, receive_json_task], + return_when=asyncio.FIRST_COMPLETED) for task in done: if task == poll_values_task: if websocket_handler.has_clients: values = await serial_handler.get_values() await websocket_handler.broadcast_values(values) - poll_values_task = asyncio.create_task(asyncio.sleep(poll_values_wait_seconds)) + poll_values_task = asyncio.create_task( + asyncio.sleep(poll_values_wait_seconds)) if task == receive_json_task: data = await task await handle_client_message(data) websocket_handler.task_done() - receive_json_task = asyncio.create_task(websocket_handler.receive_json()) + receive_json_task = asyncio.create_task( + websocket_handler.receive_json()) except (serial.SerialException, SerialReadTimeoutError) as e: # In case of serial error, disconnect all clients. The WebUI will try to reconnect. @@ -668,47 +718,51 @@ async def handle_client_message(data): logger.exception('Serial error: %s', e) websocket_handler.serial_connected = False defaults_handler.set_profile_handler(None) - await websocket_handler.close_websockets(code=WSCloseCode.INTERNAL_ERROR, message='Serial error') + await websocket_handler.close_websockets(code=WSCloseCode.INTERNAL_ERROR, + message='Serial error') await asyncio.sleep(3) + def main(): """Set up and run the http server.""" defaults_handler = DefaultsHandler() websocket_handler = WebSocketHandler() build_dir = os.path.abspath( - os.path.join(os.path.dirname(__file__), '..', 'build') - ) + os.path.join(os.path.dirname(__file__), '..', 'build')) if NO_SERIAL: serial_handler = FakeSerialHandler(num_sensors=4) else: - serial_handler = SerialHandler(SyncSerialSender(port=SERIAL_PORT, timeout=0.05)) + serial_handler = SerialHandler( + SyncSerialSender(port=SERIAL_PORT, timeout=0.05)) async def on_startup(app): - del app # unused - asyncio.create_task(run_main_task_loop(websocket_handler=websocket_handler, - serial_handler=serial_handler, - defaults_handler=defaults_handler)) + del app # unused + asyncio.create_task( + run_main_task_loop(websocket_handler=websocket_handler, + serial_handler=serial_handler, + defaults_handler=defaults_handler)) async def on_shutdown(app): - del app # unused - await websocket_handler.close_websockets(code=WSCloseCode.GOING_AWAY, message='Server shutdown') + del app # unused + await websocket_handler.close_websockets(code=WSCloseCode.GOING_AWAY, + message='Server shutdown') async def get_index(request): - del request # unused + del request # unused return web.FileResponse(os.path.join(build_dir, 'index.html')) app = web.Application() app.add_routes([ - web.get('/defaults', defaults_handler.handle_defaults), - web.get('/ws', websocket_handler.handle_ws), + web.get('/defaults', defaults_handler.handle_defaults), + web.get('/ws', websocket_handler.handle_ws), ]) if SERVE_STATIC_FRONTEND_FILES: app.add_routes([ - web.get('/', get_index), - web.get('/plot', get_index), - web.static('/', build_dir), + web.get('/', get_index), + web.get('/plot', get_index), + web.static('/', build_dir), ]) app.on_shutdown.append(on_shutdown) app.on_startup.append(on_startup) @@ -719,5 +773,6 @@ async def get_index(request): web.run_app(app, port=HTTP_PORT) + if __name__ == '__main__': main() From a27ae6874fc1e1d6a2fb60424585b81eb4fd2619 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Wed, 16 Feb 2022 01:17:36 -0500 Subject: [PATCH 55/56] Fix line lengths in server.py --- webui/server/server.py | 79 +++++++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 39 deletions(-) diff --git a/webui/server/server.py b/webui/server/server.py index d6ae0c9..a15ff6a 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -46,7 +46,8 @@ def __init__(self, num_sensors, filename='profiles.txt'): """ Keyword arguments: num_sensors -- all profiles are expected to have this many sensors - filename -- relative path for file safe/load profiles (default 'profiles.txt') + filename -- relative path for file safe/load profiles + (default 'profiles.txt') """ self._num_sensors = num_sensors self._filename = filename @@ -72,8 +73,8 @@ def _save(self): def load(self): """ - Load profiles from file if it exists, and change the to the first profile found. - If no profiles are found, do not change the current profile. + Load profiles from file if it exists, and change the to the first profile + found. If no profiles are found, do not change the current profile. """ num_profiles = 0 if os.path.exists(self._filename): @@ -94,7 +95,7 @@ def get_cur_thresholds(self): def update_threshold(self, index, value): """ Update one threshold in the current profile, and save profiles to file. - + Keyword arguments: index -- threshold index to update value -- new threshold value @@ -106,7 +107,7 @@ def update_thresholds(self, values): """ Update all thresholds in the current profile, and save profiles to file. The number of values must match the configured num_panels. - + Keyword arguments: thresholds -- list of new threshold values """ @@ -134,8 +135,8 @@ def get_profile_names(self): def add_profile(self, profile_name, thresholds): """ - If the current profile is the empty-name '' profile, reset thresholds to defaults. - Add a profile, change to it, and save profiles to file. + If the current profile is the empty-name '' profile, reset thresholds to + defaults. Add a profile, change to it, and save profiles to file. Keyword arguments: profile_name -- the name of the new profile @@ -330,7 +331,8 @@ async def get_values(self): async def get_thresholds(self): """ - Read current threshold values from serial device and return as a list of ints. + Read current threshold values from serial device and return as a list of + ints. """ response = await asyncio.to_thread( lambda: self._sync_serial_sender.send('t\n')) @@ -345,7 +347,8 @@ async def get_thresholds(self): async def update_threshold(self, index, threshold): """ Write a single threshold update command. - Read all current threshold values from serial device and return as a list of ints. + Read all current threshold values from serial device and return as a list of + ints. Keyword arguments: index -- index starting from 0 of the threshold to update @@ -440,7 +443,7 @@ async def send_json_all(self, msg): async def broadcast_thresholds(self, thresholds): """ Send current thresholds to all connected clients - + Keyword arguments: thresholds -- threshold values as list of ints """ @@ -482,7 +485,8 @@ async def close_websockets(self, code=WSCloseCode.OK, message=b''): Keyword arguments: code -- closing code (default WSCloseCode.OK) - message -- optional payload of close message, str (converted to UTF-8 encoded bytes) or bytes. + message -- optional payload of close message, str (converted to UTF-8 + encoded bytes) or bytes. """ # Iterate over copy of set in case the set is modified while awaiting websockets = self._websockets.copy() @@ -584,14 +588,14 @@ async def run_main_task_loop(websocket_handler, serial_handler, Disconnect clients and retry serial connection in case of serial errors. Keyword arguments: - websocket_handler -- Should be the same instance that the aiohttp server is using - handle requests. Used for receiving messages from any client and broadcasting - messages to all clients. - serial_handler -- Preconfigured with port and timeout, not expected to be open - initially. - defaults_handler -- Should be the same instance that the aiohttp server is using - to handle requests. The main task loop creates a ProfileHandler instance and - shares it with defaults_handler when it's ready. + websocket_handler -- Should be the same instance that the aiohttp server is + using handle requests. Used for receiving messages from any client and + broadcasting messages to all clients. + serial_handler -- Preconfigured with port and timeout, not expected to be + open initially. + defaults_handler -- Should be the same instance that the aiohttp server is + using to handle requests. The main task loop creates a ProfileHandler + instance and shares it with defaults_handler when it's ready. """ profile_handler = None @@ -600,18 +604,16 @@ async def update_threshold(values, index): profile_handler.update_thresholds(thresholds) await websocket_handler.broadcast_thresholds( profile_handler.get_cur_thresholds()) - print( - f'Profile is "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}' - ) + print(f'Profile is "{profile_handler.get_current_profile()}".', + f'Thresholds are: {str(profile_handler.get_cur_thresholds())}') async def update_thresholds(values): thresholds = await serial_handler.update_thresholds(values) profile_handler.update_thresholds(thresholds) await websocket_handler.broadcast_thresholds( profile_handler.get_cur_thresholds()) - print( - f'Profile is "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}' - ) + print(f'Profile is "{profile_handler.get_current_profile()}".', + f'Thresholds are: {str(profile_handler.get_cur_thresholds())}') async def add_profile(profile_name, thresholds): profile_handler.add_profile(profile_name, thresholds) @@ -621,9 +623,8 @@ async def add_profile(profile_name, thresholds): profile_handler.get_profile_names()) await websocket_handler.broadcast_cur_profile( profile_handler.get_current_profile()) - print( - f'Changed to new profile "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}' - ) + print(f'Changed to new profile "{profile_handler.get_current_profile()}".', + f'Thresholds are: {str(profile_handler.get_cur_thresholds())}') async def remove_profile(profile_name): profile_handler.remove_profile(profile_name) @@ -634,9 +635,9 @@ async def remove_profile(profile_name): profile_handler.get_profile_names()) await websocket_handler.broadcast_cur_profile( profile_handler.get_current_profile()) - print( - f'Removed profile "{profile_name}". Profile is "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}' - ) + print(f'Removed profile "{profile_name}".', + f'Profile is "{profile_handler.get_current_profile()}".', + f'Thresholds are: {str(profile_handler.get_cur_thresholds())}') async def change_profile(profile_name): profile_handler.change_profile(profile_name) @@ -645,9 +646,8 @@ async def change_profile(profile_name): await update_thresholds(thresholds) await websocket_handler.broadcast_cur_profile( profile_handler.get_current_profile()) - print( - f'Changed to profile "{profile_handler.get_current_profile()}". Thresholds are: {str(profile_handler.get_cur_thresholds())}' - ) + print(f'Changed to profile "{profile_handler.get_current_profile()}".', + f'Thresholds are: {str(profile_handler.get_cur_thresholds())}') async def handle_client_message(data): action = data[0] @@ -677,13 +677,13 @@ async def handle_client_message(data): profile_handler.load() print('Found Profiles: ' + str(list(profile_handler.get_profile_names()))) - # Send current thresholds from loaded profile, then write back from MCU to profiles. + # Send current thresholds from loaded profile, then write back from MCU + # to profiles. thresholds = profile_handler.get_cur_thresholds() thresholds = await serial_handler.update_thresholds(thresholds) profile_handler.update_thresholds(thresholds) - print( - f'Profile is "{profile_handler.get_current_profile()}". Thresholds are: {profile_handler.get_cur_thresholds()}' - ) + print(f'Profile is "{profile_handler.get_current_profile()}".', + f'Thresholds are: {profile_handler.get_cur_thresholds()}') # Handle GET /defaults using new profile_handler defaults_handler.set_profile_handler(profile_handler) @@ -713,7 +713,8 @@ async def handle_client_message(data): websocket_handler.receive_json()) except (serial.SerialException, SerialReadTimeoutError) as e: - # In case of serial error, disconnect all clients. The WebUI will try to reconnect. + # In case of serial error, disconnect all clients. The WebUI will try to + # reconnect. serial_handler.close() logger.exception('Serial error: %s', e) websocket_handler.serial_connected = False From eb7b1821d71fe41b7936da435f68b08a9a7813b1 Mon Sep 17 00:00:00 2001 From: Josh Headapohl Date: Wed, 16 Feb 2022 01:22:49 -0500 Subject: [PATCH 56/56] Add basic module docstring --- webui/server/server.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/webui/server/server.py b/webui/server/server.py index a15ff6a..78dab4d 100755 --- a/webui/server/server.py +++ b/webui/server/server.py @@ -1,4 +1,10 @@ #!/usr/bin/env python +""" +FSR WebUI Server + +Connect to a serial device and poll it for sensor values. +Handle incoming commands from WebUI clients. +""" import asyncio import logging import os