diff --git a/python/controller.py b/python/controller.py new file mode 100644 index 0000000..f293210 --- /dev/null +++ b/python/controller.py @@ -0,0 +1,216 @@ +from threading import Thread +from flask import Flask, jsonify, send_file +from time import sleep +from dataclasses import dataclass, field +from datetime import datetime, timedelta +import serial +import uuid +from collections import deque +import sys +import enum +import struct +import time + +from obus import Message, ModuleAddress + + +INFO_ROUND_DURATION = timedelta(seconds=3) +GAMESTATE_UPDATE_INTERVAL = timedelta(seconds=0.5) + + +@enum.unique +class Gamestate(enum.Enum): + INACTIVE = 0 + INFO = 1 + DISCOVER = 2 + GAME = 3 + GAMEOVER = 4 + + + +@dataclass +class PuzzleState: + '''State keeping object for puzzle and needy modules''' + strike_amount: int = 0 + solved: bool = False + + +@dataclass +class SharedWebToSerial: + game_duration: timedelta = timedelta(seconds=60) + max_allowed_strikes: int = 3 + seed: int = 0 + blocked_modules: list[ModuleAddress] = field(default_factory=list) + start_game: bool = False + restart_game: bool = False + + +@dataclass +class SharedSerialToWeb: + gamestate: Gamestate = Gamestate.INACTIVE + info_round_start: datetime = None + discover_round_start: datetime = None + game_start: datetime = None + game_stop: datetime = None + last_state_update: datetime = None + registered_modules: dict[ModuleAddress, PuzzleState] = field(default_factory=dict) + + +app = Flask(__name__) + +server_id = uuid.uuid4() +print("Server ID: ", server_id) + +web_to_serial = SharedWebToSerial() +serial_to_web = SharedSerialToWeb() + + +def parse_can_line(ser) -> Message: + if not ser.in_waiting: + return None + line = ser.read(12) + if len(line) == 12: + if line == b'BEGIN START\n' or line[0] > 0b111: + return None + sender = (int(line[0]) << 8) + int(line[1]) + size = int(line[2]) + message = line[3:3+size] + return Message(message, sender, datetime.now()) + return None + + +def send_message(ser, msg) -> None: + # we send the payload padded with null-bytes, but these don't actually get sent + packed = struct.pack('>HB8s', msg.module_address().as_binary(), len(msg.payload), msg.payload) + ser.write(packed + b'\n') + +def calculate_puzzle_modules_left(serial_to_web) -> int: + return sum(address.is_puzzle() and not state.solved for address, state in serial_to_web.registered_modules.items()) + +def calculate_strikes(serial_to_web) -> int: + return sum(state.strike_amount for state in serial_to_web.registered_modules.values()) + +def serial_controller(serialport, web_to_serial, serial_to_web): + with serial.Serial(serialport, 115200, timeout=0.05) as ser: + serial_to_web.gamestate = Gamestate.INACTIVE + # TODO send message here to get all modules to stop talking and reset + ser.reset_input_buffer() + time.sleep(5) + while True: + if serial_to_web.gamestate == Gamestate.INACTIVE: + send_message(ser, Message.create_controller_infostart(web_to_serial.seed)) + serial_to_web.gamestate = Gamestate.INFO + serial_to_web.info_round_start = datetime.now() + serial_to_web.registered_modules = {} + elif serial_to_web.gamestate == Gamestate.INFO: + parse_can_line(ser) # throw away, TODO keep this and display it + if datetime.now() - serial_to_web.info_round_start > INFO_ROUND_DURATION: + serial_to_web.gamestate = Gamestate.DISCOVER + send_message(ser, Message.create_controller_hello()) + elif serial_to_web.gamestate == Gamestate.DISCOVER: + if web_to_serial.start_game: + web_to_serial.start_game = False + serial_to_web.game_start = datetime.now() + serial_to_web.last_state_update = datetime.now() + serial_to_web.gamestate = Gamestate.GAME + send_message(ser, Message.create_controller_gamestart(web_to_serial.game_duration, 0, web_to_serial.max_allowed_strikes, len(serial_to_web.registered_modules))) + msg = parse_can_line(ser) + if msg is None: + continue + puzzle_address = msg.get_puzzle_register() + if puzzle_address is None: + continue + if puzzle_address in web_to_serial.blocked_modules: + # this is blocked puzzle module, don't ack it + continue + serial_to_web.registered_modules[puzzle_address] = PuzzleState() + send_message(ser, Message.create_controller_ack(msg.module_address())) + + elif serial_to_web.gamestate == Gamestate.GAME: + # React to puzzle strike / solve + msg = parse_can_line(ser) + if msg is None: + pass + elif (strike_details := msg.get_puzzle_strike_details()): + strike_address, strike_amount = strike_details + serial_to_web.registered_modules[strike_address].strike_amount = strike_amount + elif (solved_puzzle_address := msg.get_puzzle_solved()): + serial_to_web.registered_modules[solved_puzzle_address].solved = True + + # Handle strikeout / timeout / solve + time_left = web_to_serial.game_duration - (datetime.now() - serial_to_web.game_start) + puzzle_modules_left = calculate_puzzle_modules_left(serial_to_web) + total_strikes = calculate_strikes(serial_to_web) + if time_left.total_seconds() <= 0: + # Pass zero timedelta, because time left can't be negative in the CAN protocol + # Timeout case is also handled first, so that in other cases we know there's time left + send_message(ser, Message.create_controller_timeout(timedelta(), puzzle_modules_left, web_to_serial.max_allowed_strikes, puzzle_modules_left)) + serial_to_web.gamestate = Gamestate.GAMEOVER + elif total_strikes > web_to_serial.max_allowed_strikes: + send_message(ser, Message.create_controller_strikeout(time_left, puzzle_modules_left, web_to_serial.max_allowed_strikes, puzzle_modules_left)) + serial_to_web.gamestate = Gamestate.GAMEOVER + elif puzzle_modules_left == 0: + send_message(ser, Message.create_controller_solved(time_left, puzzle_modules_left, web_to_serial.max_allowed_strikes, puzzle_modules_left)) + serial_to_web.gamestate = Gamestate.GAMEOVER + if serial_to_web.gamestate == Gamestate.GAMEOVER: + serial_to_web.game_stop = datetime.now() + continue + + if datetime.now() - serial_to_web.last_state_update > GAMESTATE_UPDATE_INTERVAL: + serial_to_web.last_state_update = datetime.now() + # Send state update with known-good checked values + send_message(ser, Message.create_controller_state(time_left, total_strikes, web_to_serial.max_allowed_strikes, puzzle_modules_left)) + + elif serial_to_web.gamestate == Gamestate.GAMEOVER: + if web_to_serial.restart_game: + web_to_serial.restart_game = False + serial_to_web.gamestate = Gamestate.INACTIVE + + + +@app.route('/status.json') +def status(): + status_dict = { + 'gamestate': serial_to_web.gamestate.name + } + if serial_to_web.gamestate == Gamestate.GAME: + # Send the time left to avoid time syncronisation issues between server and client + # Client can then extrapolate if it wants to + status_dict['timeleft'] = (datetime.now() - serial_to_web.game_start).total_seconds() + elif serial_to_web.gamestate == Gamestate.GAMEOVER: + status_dict['timeleft'] = (serial_to_web.game_stop - serial_to_web.game_start).total_seconds() + + if serial_to_web.gamestate in (Gamestate.DISCOVER, Gamestate.GAME, Gamestate.GAMEOVER): + status_dict['puzzles'] = [ + {'address': address.to_binary(), 'solved': state.solved if address.is_puzzle() else None, 'strikes': state.strike_amount} + for address, state + in serial_to_web.registered_modules.items() + ] + return jsonify(status_dict) + +@app.route('/start') +def start(): + if serial_to_web.gamestate == Gamestate.DISCOVER: + web_to_serial.start_game = True + return 'OK' + return 'Wrong gamestage' + +@app.route('/restart') +def restart(): + if serial_to_web.gamestate == Gamestate.GAMEOVER: + web_to_serial.restart_game = True + return 'OK' + return 'Wrong gamestage' + + +@app.route('/') +def index(): + return send_file('static/controller.html') + +if __name__ == '__main__': + if len(sys.argv) != 2: + print("Usage: python3 controller.py [serial port]") + sys.exit() + thread = Thread(target=serial_controller, args=(sys.argv[1], web_to_serial, serial_to_web)) + thread.start() + app.run(debug=False, host='0.0.0.0', port=8080) diff --git a/python/debugserver.py b/python/debugserver.py index ce01074..f9c2261 100644 --- a/python/debugserver.py +++ b/python/debugserver.py @@ -24,8 +24,8 @@ max_message_cache = 200 shared_data = SharedData(deque(maxlen=max_message_cache), -1) -def serial_reader(shared_data): - with serial.Serial('/dev/ttyUSB0', 115200, timeout=0.05) as ser: +def serial_reader(serialport, shared_data): + with serial.Serial(serialport, 115200, timeout=0.05) as ser: while True: line = ser.read(12) if not line: @@ -55,6 +55,9 @@ def api(last_received): return jsonify({"server_id": server_id, "newest_msg": shared_data.last_message_index, "messages": list(shared_data.messages)[len(shared_data.messages) - (shared_data.last_message_index - last_received):]}) if __name__ == '__main__': - thread = Thread(target=serial_reader, args=(shared_data, )) + if len(sys.argv) != 2: + print("Usage: python3 debugserver.py [serial port]") + sys.exit() + thread = Thread(target=serial_reader, args=(sys.argv[1], shared_data, )) thread.start() app.run(debug=False, host='0.0.0.0') diff --git a/python/obus.py b/python/obus.py index 823dd4a..8ac5554 100644 --- a/python/obus.py +++ b/python/obus.py @@ -1,5 +1,31 @@ from dataclasses import dataclass from datetime import datetime +import struct +import enum +from typing import NamedTuple + + +@enum.unique +class ControllerMessageType(enum.Enum): + ACK = 0 + HELLO = 1 + GAMESTART = 2 + STATE = 3 + SOLVED = 4 + TIMEOUT = 5 + STRIKEOUT = 6 + INFOSTART = 7 + + +class ModuleAddress(NamedTuple): + module_type: int + identifier: int + + def is_puzzle(self): + return self.module_type == 1 + + def as_binary(self): + return (self.module_type << 8) | self.identifier @dataclass @@ -8,6 +34,67 @@ class Message: received_from: int received_at: datetime + @classmethod + def create_controller_message(cls, controller_message_type, content): + return cls(bytes([controller_message_type.value]) + content, 0, datetime.now()) + + @classmethod + def create_controller_infostart(cls, seed): + assert 0 <= seed <= 0xFFFFFFFF + return cls.create_controller_message(ControllerMessageType.INFOSTART, struct.pack('>L', seed)) + + @classmethod + def create_controller_ack(cls, module_address): + return cls.create_controller_message(ControllerMessageType.ACK, struct.pack('>H', module_address.as_binary())) + + @classmethod + def create_controller_hello(cls): + return cls.create_controller_message(ControllerMessageType.HELLO, b'') + + @classmethod + def create_controller_generic_stateupdate(cls, controller_message_type, timeleft, current_strikes, max_strikes, amount_puzzle_modules_left): + time_left_ms = int(timeleft.total_seconds() * 1000) + assert 0 <= time_left_ms <= 0xFFFFFFFF + return cls.create_controller_message(controller_message_type, struct.pack('>LBBB', time_left_ms, current_strikes, max_strikes, amount_puzzle_modules_left)) + + @classmethod + def create_controller_gamestart(cls, *args): + return cls.create_controller_generic_stateupdate(ControllerMessageType.GAMESTART, *args) + + @classmethod + def create_controller_state(cls, *args): + return cls.create_controller_generic_stateupdate(ControllerMessageType.STATE, *args) + + @classmethod + def create_controller_solved(cls, *args): + return cls.create_controller_generic_stateupdate(ControllerMessageType.SOLVED, *args) + + @classmethod + def create_controller_timeout(cls, *args): + return cls.create_controller_generic_stateupdate(ControllerMessageType.TIMEOUT, *args) + + @classmethod + def create_controller_strikeout(cls, *args): + return cls.create_controller_generic_stateupdate(ControllerMessageType.STRIKEOUT, *args) + + def get_puzzle_register(self): + '''Returns the address of the puzzle if this is a register puzzle message, None otherwise''' + if self.sender_type() == 1 and self.payload[0] == 0: + return self.module_address() + return None + + def get_puzzle_strike_details(self): + '''Returns the address and amount of strikes of the puzzle if this is a puzzle strike message, None otherwise''' + if self.sender_type() == 1 and self.payload[0] == 1: + return (self.module_address(), self.payload[1]) + return None + + def get_puzzle_solved(self): + '''Returns the address of the puzzle if this is a solved puzzle message, None otherwise''' + if self.sender_type() == 1 and self.payload[0] == 2: + return self.module_address() + return None + def readable_time(self): return self.received_at.strftime('%H:%M:%S') @@ -20,6 +107,9 @@ class Message: def sender_id(self): return (self.received_from >> 0) & 0b1111_1111 + def module_address(self): + return ModuleAddress(self.sender_type(), self.sender_id()) + @staticmethod def human_readable_type(sender_type, sender_id): return [('controller' if sender_id == 0 else 'info'), 'puzzle', 'needy', 'RESERVED TYPE'][sender_type] diff --git a/python/static/controller.html b/python/static/controller.html new file mode 100644 index 0000000..f870a08 --- /dev/null +++ b/python/static/controller.html @@ -0,0 +1,14 @@ + + + + + + OBUS controller + + + + + + + + diff --git a/python/static/controller.js b/python/static/controller.js new file mode 100644 index 0000000..4155f67 --- /dev/null +++ b/python/static/controller.js @@ -0,0 +1,9 @@ + + +function startbutton() { + fetch('/start'); +} + +function restartbutton() { + fetch('/restart'); +}