From 332c304442ab01427362b91e6a4454f4657c6f24 Mon Sep 17 00:00:00 2001 From: lebaston100 <26111114+lebaston100@users.noreply.github.com> Date: Fri, 1 Mar 2024 20:04:27 +0100 Subject: [PATCH] Add ContactGroup implementation --- server/config.conf | 14 +- server/modules/AvatarPoint.py | 44 ++++- server/modules/ContactGroup.py | 290 ++++++++++++++++++++++++++++++ server/modules/HardwareDevice.py | 5 +- server/modules/HwManager.py | 7 +- server/modules/Motor.py | 7 +- server/modules/Points.py | 7 +- server/modules/Server.py | 33 ++-- server/modules/Solver.py | 109 +++++++---- server/modules/VrcConnector.py | 5 +- server/modules/__init__.py | 7 +- server/ui/ContactGroupSettings.py | 13 +- server/ui/MainWindow.py | 119 ++++++++---- 13 files changed, 530 insertions(+), 130 deletions(-) create mode 100644 server/modules/ContactGroup.py diff --git a/server/config.conf b/server/config.conf index 538551e..f693576 100644 --- a/server/config.conf +++ b/server/config.conf @@ -4,7 +4,6 @@ "vrcOscSendPort": 9001, "vrcOscReceivePort": 9000, "vrcOscReceiveAddress": "127.0.0.1", - "mqttBrokerIp": "10.2.1.11", "mainTps": 40, "logLevel": "DEBUG" }, @@ -40,7 +39,7 @@ "groups": { "group0": { "id": 0, - "name": "Head", + "name": "Head 11", "motors": [ { "name": "Motor 1", @@ -76,7 +75,7 @@ "avatarPoints": [ { "name": "Point 1", - "receiverId": "rec_1", + "receiverId": "pat_1", "xyz": [ 1.0, 2.0001, @@ -86,9 +85,9 @@ }, { "name": "Point 2", - "receiverId": "rec_2", + "receiverId": "pat_2", "xyz": [ - 5.2201, + 5.0201, 18.0001, 8.0001 ], @@ -96,9 +95,10 @@ } ], "solver": { - "solverType": "Mlat", + "solverType": "MLat", + "strength": 100, "enableHalfSphereCheck": true, - "contactOnly": false + "contactOnly": true } } } diff --git a/server/modules/AvatarPoint.py b/server/modules/AvatarPoint.py index 279903d..ab4e54f 100644 --- a/server/modules/AvatarPoint.py +++ b/server/modules/AvatarPoint.py @@ -1,11 +1,41 @@ +from PyQt6.QtCore import QObject + from modules.Points import Sphere3D +from utils import LoggerClass + +logger = LoggerClass.getSubLogger(__name__) -class AvatarPoint(Sphere3D): +class AvatarPointSphere(Sphere3D): def __init__(self, settings: dict, *args, **kwargs) -> None: - self._name: str = settings["name"] - self._receiverId: str = settings["receiverId"] - self._xyz: list[int] = settings["xyz"] - self._radius: float = settings["r"] - super().__init__(self._name, self._radius, *args, **kwargs) - self.setXYZ(self._xyz) + self.name: str = settings["name"] + self.radius: float = settings["r"] + + # Handle multi-inheritance with different signatures + Sphere3D.__init__(self, self.name, self.radius) + # QObject.__init__(self) + + self.xyz: tuple[float, ...] = settings["xyz"] + self.receiverId: str = settings["receiverId"] + self.lastValue = 0.0 + self.lastValueTs: float | None = None + + def vrcContact(self, time: float, params: list): + """The callback send by the ContactGroupManager when new data + from VRC comes in for this contact receiver + + Args: + time (float): The osc message creation time + params (list): The osc parameters + """ + try: + self.lastValue = params[0] + self.lastValueTs = time + logger.debug(f"received new value for {self.receiverId}: " + f"{self.lastValue}") + except Exception as E: + logger.exception(E) + + def __repr__(self) -> str: + return __class__.__name__ + ":" + ";"\ + .join([f"{key}={str(val)}" for key, val in self.__dict__.items()]) diff --git a/server/modules/ContactGroup.py b/server/modules/ContactGroup.py new file mode 100644 index 0000000..4a42d7e --- /dev/null +++ b/server/modules/ContactGroup.py @@ -0,0 +1,290 @@ +"""This module handles everything related to running solvers""" + +import time + +from PyQt6.QtCore import QObject, Qt, QThread, QTimer +from PyQt6.QtCore import pyqtSignal as QSignal +from PyQt6.QtCore import pyqtSlot as QSlot + +from modules import config +from modules.AvatarPoint import AvatarPointSphere +from modules.Motor import Motor +from modules.Solver import SolverFactory +from utils import LoggerClass, threadAsStr + +logger = LoggerClass.getSubLogger(__name__) + + +class ContactGroup(QObject): + dataRxStateChanged = QSignal(bool) + avatarPointAdded = QSignal(object) + avatarPointRemoved = QSignal(object) + motorSpeedChanged = QSignal(int, int, int) + strengthSliderValueChanged = QSignal(int) + + def __init__(self, configKey) -> None: + logger.debug(f"Creating {__class__.__name__}({configKey})") + super().__init__() + self._configKey = configKey + self._config = config.get(self._configKey) + + self.motors: list[Motor] = [] + self.avatarPoints: list[AvatarPointSphere] = [] + + def setup(self) -> None: + try: + self._id = self._config["id"] + self._name = self._config["name"] + + self._setupMotors() + self._setupAvatarPoints() + + solverType = self._config["solver"]["solverType"] + solverClass = SolverFactory.fromType(solverType) + if solverClass: + self.solver = solverClass( + self.motors, self.avatarPoints, self._configKey) + self.strengthSliderValueChanged.connect( + self.solver.setStrength) + self.solver.setup() + else: + logger.error("Unknown solver type specified") + except Exception as E: + logger.exception(E) + + def _setupMotors(self) -> None: + if self.motors: + # we are already setup, destory everything + pass + + for motor in self._config["motors"]: + self.motors.append(Motor(motor)) + + def _setupAvatarPoints(self) -> None: + for avatarPoint in self._config["avatarPoints"]: + newAvatarPoint = AvatarPointSphere(avatarPoint) + self.avatarPoints.append(newAvatarPoint) + self.avatarPointAdded.emit(newAvatarPoint) + + def _setupSolver(self) -> None: + pass + + def _ + + def close(self) -> None: + """Closes everything we own and care for.""" + logger.debug(f"Stopping {__class__.__name__}") + for avatarPoint in self.avatarPoints: + self.avatarPointRemoved.emit(avatarPoint) + self.avatarPoints = [] + + def __repr__(self) -> str: + return __class__.__name__ + ":" + ";"\ + .join([f"{key}={str(val)}" for key, val in self.__dict__.items()]) + + +class ContactGroupManager(QObject): + registerAvatarPoint = QSignal(str) + unregisterAvatarPoint = QSignal(str) + tickSkipped = QSignal() # ?? + motorSpeedChanged = QSignal(int, int, int) + solverDone = QSignal() + contactGroupListChanged = QSignal(dict) + currentTpsChanged = QSignal(int) + _tpsSettingChanged = QSignal() + + def __init__(self, parent: QObject | None = None) -> None: + logger.debug(f"Creating {__class__.__name__}") + super().__init__(parent) + self._configKey = "groups" + self.contactGroups: dict[int, ContactGroup] = {} + self._avatarPoints: dict[str, list[AvatarPointSphere]] = {} + + self.workerThread = QThread() + self.worker = ContactGroupSolverWorker(self) + + self.workerThread.started.connect(self.worker.startTimer) + self.workerThread.finished.connect(self.worker.stopTimer) + self.worker.moveToThread(self.workerThread) + + self.workerThread.start() + + config.configPathHasChanged.connect(self._handleConfigPathChange) + config.configRootUpdateDone.connect(self._handleConfigRootChange) + + def createAllContactGroupsFromConfig(self) -> None: + """Create all ContactGroup objects from config file""" + groups = config.get(self._configKey) + for key, group in groups.items(): + newGroup = self._contactGroupFactory(key) + self.contactGroups[group["id"]] = newGroup + self.contactGroupListChanged.emit(self.contactGroups) + + def _contactGroupFactory(self, key: str) -> ContactGroup: + group = ContactGroup(f"{self._configKey}.{key}") + group.motorSpeedChanged.connect(self.motorSpeedChanged) + group.avatarPointAdded.connect(self.avatarPointAdded) + group.avatarPointRemoved.connect(self.avatarPointRemoved) + group.setup() + return group + + def avatarPointAdded(self, avatarPoint: AvatarPointSphere) -> None: + """Adds a receiver id to the LUT and adds it to the VRC filter. + + Args: + avatarPoint (AvatarPointSphere): The new AvatarPointSphere + """ + if not avatarPoint.receiverId in self._avatarPoints: + self.registerAvatarPoint.emit(avatarPoint.receiverId) + self._avatarPoints[avatarPoint.receiverId] = [] + self._avatarPoints[avatarPoint.receiverId].append(avatarPoint) + logger.debug(self._avatarPoints) + + def avatarPointRemoved(self, avatarPoint: AvatarPointSphere) -> None: + """Removes a receiver id from the LUT and unregisters it from + the VRC filter. + + Args: + avatarPoint (AvatarPointSphere): The AvatarPointSphere + beeing deleted. + """ + if avatarPoint.receiverId in self._avatarPoints and \ + avatarPoint in self._avatarPoints[avatarPoint.receiverId]: + self._avatarPoints[avatarPoint.receiverId].remove(avatarPoint) + if not len(self._avatarPoints[avatarPoint.receiverId]): + self.unregisterAvatarPoint.emit(avatarPoint.receiverId) + self._avatarPoints.pop(avatarPoint.receiverId) + logger.debug(self._avatarPoints) + + @QSlot(float, str, list) + def onVrcContact(self, ts: float, addr: str, params: list) -> None: + """Distibute data coming from vrc to the ContactPoints. + + Args: + ts (float): The osc message creation time + addr (str): The full osc path + params (list): The osc message parameter list + """ + contactName = addr[19:] + logger.debug( + f"osc @ {ts}: addr={addr} msg={str(params)} " + f"contactName = {contactName}") + if contactName in self._avatarPoints: + for point in self._avatarPoints[contactName]: + point.vrcContact(ts, params) + + @QSlot(str) + def _handleConfigPathChange(self, path: str) -> None: + logger.debug(path) + if path == "program.mainTps": + if self.workerThread: + self.workerThread.quit() + self.workerThread.wait() + self.workerThread.start() + + @QSlot(str) + def _handleConfigRootChange(self, path: str) -> None: + ... + # TODO: recreate + # motors + # contactPoints + # solver + # how do we handle this? it could be re-created a lot when multiple things change + # maybe just emit one signal on save and then go from there + + def close(self) -> None: + """Closes everything we own and care for.""" + logger.debug(f"Stopping {__class__.__name__}") + if self.workerThread: + self.workerThread.quit() + self.workerThread.wait() + for contactGroup in self.contactGroups.values(): + contactGroup.close() + + def __repr__(self) -> str: + return __class__.__name__ + ":" + ";"\ + .join([f"{key}={str(val)}" for key, val in self.__dict__.items()]) + + +class ContactGroupSolverWorker(QObject): + def __init__(self, manager: ContactGroupManager, parent: QObject | None = None) -> None: + super().__init__(parent) + self._manager = manager + self._skipflag = False + self._tpsCounter = 1000 + + @QSlot() + def startTimer(self): + logger.debug(f"startTimer in {__class__.__name__}") + selfThread = self.thread() + logger.debug(f"pid_QThread.currentThread={threadAsStr(QThread.currentThread())} " + f"pid_selfThread={threadAsStr(selfThread)}") + + # Setup solver runner timer + if not hasattr(self, "_timer"): + self._timer = QTimer(self) + self._timer.setTimerType(Qt.TimerType.PreciseTimer) + self._timer.timeout.connect(self.tick) + else: + self._timer.stop() + + # Setup tps watcher timer + if not hasattr(self, "_tpsStatTimer"): + self._tpsStatTimer = QTimer(self) + self._tpsStatTimer.timeout.connect(self._calcTps) + self._tpsStatTimer.start(1000) + + # Calculate timer time from TPS and start timer + self.tps = config.get("program.mainTps", 30) + loopTimeMs: float = 1000/self.tps + self.tickTimeNs = 1e9/self.tps + logger.debug(f"Calculated tick time: " + f"{round(loopTimeMs)}ms / {self.tickTimeNs}ns") + self._timer.start(round(loopTimeMs)) + + @QSlot() + def _calcTps(self): + """Calculate and show the number of ticks in the last 1 seconds. + """ + # logger.debug(f"ticks in the last 1000ms: {self._tpsCounter}") + if self._tpsCounter < self.tps-1: + logger.debug(f"TPS below setpoint! {self._tpsCounter} tps") + self._manager.currentTpsChanged.emit(self._tpsCounter) + self._tpsCounter = 0 + + @QSlot() + def stopTimer(self): + logger.debug(f"stopTimer in {__class__.__name__}") + if hasattr(self, "_timer"): + self._timer.stop() + if hasattr(self, "_tpsStatTimer"): + self._tpsStatTimer.stop() + del self._tpsStatTimer + + @QSlot() + def tick(self): + startTime = time.perf_counter_ns() + if self._skipflag: + self._skipflag = False + return + + # Run solver + try: + for group in self._manager.contactGroups.values(): + group.solver.solve() + except Exception as E: + logger.exception(E) + + self._manager.solverDone.emit() + self._tpsCounter += 1 + stopTime = time.perf_counter_ns() + tickTime = stopTime - startTime + if tickTime >= self.tickTimeNs: + logger.warn("Skipping next tick!") + self._skipflag = True + self._manager.tickSkipped.emit() + # logger.debug(f"tick time: {tickTime/1e6} ms") + + +if __name__ == "__main__": + print("There is no point running this file directly") diff --git a/server/modules/HardwareDevice.py b/server/modules/HardwareDevice.py index c23e28b..525553e 100644 --- a/server/modules/HardwareDevice.py +++ b/server/modules/HardwareDevice.py @@ -79,6 +79,7 @@ def setAndSendPinValues(self, channelId: int, value: int) -> None: def sendPinValues(self) -> None: """Create and send current self.pinStates to hardware.""" + # logger.debug(f"Sending all pin values for {self._name}") motorData = list(self.pinStates.values())[:self._numMotors] self.hardwareCommunicationAdapter.sendPinValues(motorData) self.motorDataSent.emit(motorData) @@ -134,7 +135,7 @@ def close(self) -> None: self.hardwareCommunicationAdapter.close() def __repr__(self) -> str: - return __class__.__name__ + ";"\ + return __class__.__name__ + ":" + ";"\ .join([f"{key}={str(val)}" for key, val in self.__dict__.items()]) @@ -160,7 +161,7 @@ def close(self) -> None: raise NotImplementedError def __repr__(self) -> str: - return __class__.__name__ + ";"\ + return __class__.__name__ + ":" + ";"\ .join([f"{key}={str(val)}" for key, val in self.__dict__.items()]) diff --git a/server/modules/HwManager.py b/server/modules/HwManager.py index 915c37a..e835b62 100644 --- a/server/modules/HwManager.py +++ b/server/modules/HwManager.py @@ -29,6 +29,7 @@ class HwManager(QObject): def __init__(self, *args, **kwargs) -> None: logger.debug(f"Creating {__class__.__name__}") super().__init__(*args, **kwargs) + self._configKey = "esps" self.hardwareDevices: dict[int, HardwareDevice] = {} @@ -82,7 +83,7 @@ def sendHwUpdate(self) -> None: def createAllHardwareDevicesFromConfig(self) -> None: """Creates all HardwareDevice objects from the config file.""" logger.debug("(Re-)creating all HardwareDevice objects") - devices = config.get("esps") + devices = config.get(self._configKey) for key, device in devices.items(): newDevice = self._deviceFactory(key) self.hardwareDevices[device["id"]] = newDevice @@ -96,7 +97,7 @@ def _handleConfigChange(self, key: str) -> None: Args: key (str): The key of the config parameter that was changed. """ - if key.startswith("esps.esp"): + if key.startswith(f"{self._configKey}.esp"): keys = key.split(".") id = config.get(f"{".".join(keys[:2])}.id") if id in self.hardwareDevices: @@ -106,7 +107,7 @@ def _handleConfigChange(self, key: str) -> None: self.hwListChanged.emit(self.hardwareDevices) def _handleConfigRemoved(self, key: str) -> None: - # You cannot remove HardwareDevices right now + # You cannot remove HardwareDevices (at runtime) right now logger.debug(key) def _deviceFactory(self, key: str) -> HardwareDevice: diff --git a/server/modules/Motor.py b/server/modules/Motor.py index 8927cb6..739ada9 100644 --- a/server/modules/Motor.py +++ b/server/modules/Motor.py @@ -4,11 +4,14 @@ from PyQt6.QtCore import pyqtSignal as QSignal from modules.Points import Sphere3D +from utils import LoggerClass + +logger = LoggerClass.getSubLogger(__name__) class Motor(QObject): """Represents a motor attached to an ESP Pin (Channel)""" - speedChanged = QSignal(list, float) + speedChanged = QSignal(int, int, float) def __init__(self, settings: dict, parent: QObject | None = None) -> None: super().__init__(parent) @@ -42,5 +45,5 @@ def setSpeed(self, newSpeed: float) -> None: self.speedChanged.emit(*self._espAddr, self.currentPWM) def __repr__(self) -> str: - return __class__.__name__ + ";"\ + return __class__.__name__ + ":" + ";"\ .join([f"{key}={str(val)}" for key, val in self.__dict__.items()]) diff --git a/server/modules/Points.py b/server/modules/Points.py index 851c37e..0d1a0b4 100644 --- a/server/modules/Points.py +++ b/server/modules/Points.py @@ -9,11 +9,6 @@ class Sphere3D(QVector3D): """A 3D Point with a name and radius.""" - @classmethod - # TODO get data from config and pass to constructor - def fromConfig(cls: type[T], config, key: str) -> T: - return cls() - def __init__(self, name: str = "", radius: float = 0, x: float = 0, y: float = 0, z: float = 0, *args, **kwargs) -> None: @@ -26,7 +21,7 @@ def __init__(self, name: str = "", radius: float = 0, z (float, optional): Z. Defaults to 0. radius (float, optional): Radius. Defaults to 0. """ - super().__init__(x, y, z, *args, **kwargs) + super().__init__(x, y, z) self._radius = radius self._name = name diff --git a/server/modules/Server.py b/server/modules/Server.py index 138b2de..553c811 100644 --- a/server/modules/Server.py +++ b/server/modules/Server.py @@ -4,8 +4,8 @@ from modules import config from modules.HwManager import HwManager -from modules.Solver import SolverRunner from modules.VrcConnector import VrcConnectorImpl +from modules.ContactGroup import ContactGroupManager from utils import LoggerClass, threadAsStr logger = LoggerClass.getSubLogger(__name__) @@ -37,33 +37,34 @@ def __init__(self, *args, **kwargs) -> None: self.vrcOscConnector = VrcConnectorImpl(config) self.vrcOscConnector.connect() - # TODO: Connect this to ContactGroups instead - self.vrcOscConnector.onVrcContact.connect(self._vrcOscDataReceived) - # self.vrcOscConnector.addToFilter("pat_2") # This will be signal triggered + # This will be signal triggered + # self.vrcOscConnector.addToFilter("pat_2") self.hwManager = HwManager() - # ContactGroupManager here + self.contactGroupManager = ContactGroupManager() + + self.vrcOscConnector.onVrcContact.connect( + self.contactGroupManager.onVrcContact) + self.contactGroupManager.registerAvatarPoint.connect( + self.vrcOscConnector.addToFilter) + self.contactGroupManager.unregisterAvatarPoint.connect( + self.vrcOscConnector.removeFromFilter) + self.contactGroupManager.solverDone.connect( + self.hwManager.sendHwUpdate) self.hwManager.createAllHardwareDevicesFromConfig() + self.contactGroupManager.createAllContactGroupsFromConfig() ServerSingleton.__instance = self - def _vrcOscDataReceived(self, ts: float, addr: str, params: list) -> None: - """Handle osc data coming from VRChat. - We can distribute the contacts to the right signal here. - - Args: - client (tuple): Remote ip/port - addr (str): The osc path - params (list): The parameter list depnding on the addr - """ - logger.info(f"osc @ {ts}: addr={addr} msg={str(params)}") - def stop(self) -> None: """Do everything needed to stop the server.""" logger.debug(f"Stopping {__class__.__name__}") + if hasattr(self, "contactGroupManager"): + self.contactGroupManager.close() + if hasattr(self, "vrcOscConnector"): self.vrcOscConnector.close() diff --git a/server/modules/Solver.py b/server/modules/Solver.py index 1e6c33c..c136b3e 100644 --- a/server/modules/Solver.py +++ b/server/modules/Solver.py @@ -1,48 +1,95 @@ -from typing import TYPE_CHECKING, TypeVar +import time +from multilateration import Engine from PyQt6.QtCore import QObject +from PyQt6.QtCore import pyqtSlot as QSlot +from modules import config +from modules.AvatarPoint import AvatarPointSphere +from modules.Motor import Motor from utils import LoggerClass, SolverType -if TYPE_CHECKING: - from modules.GlobalConfig import GlobalConfigSingleton - logger = LoggerClass.getSubLogger(__name__) -""" -Using the factory pattern without ABC because it's -a mess when combining with QObject -""" -T = TypeVar('T', bound='GlobalConfigSingleton') +class ISolver(QObject): + """The interface/base class.""" + def __init__(self, avatarPoints: AvatarPointSphere, + motors: Motor, + configKey: str) -> None: + super().__init__() + self._avatarPoints = avatarPoints + self._motors = motors + self._configKey = configKey + self._loadConfig() + # logger.debug(self) + + def _loadConfig(self) -> None: + self._config = config.get(f"{self._configKey}.solver") + if not self._config: + logger.error("Failed to load config for solver") + + def setup(self) -> None: + """A generic setup method to be reimplemented.""" + raise NotImplementedError -class ISolver(): - """The interface.""" + def setStrength(self, strength: int) -> None: + """A generic setStrength method to be reimplemented.""" + raise NotImplementedError - def solve(self): + def solve(self) -> None: """A generic solve method to be reimplemented.""" raise NotImplementedError + def __repr__(self) -> str: + return __class__.__name__ + ":" + ";"\ + .join([f"{key}={str(val)}" for key, val in self.__dict__.items()]) -class LinearSolver(ISolver, QObject): - def __init__(self, config) -> None: - logger.debug(config) - super().__init__() - def solve(self): +class LinearSolver(ISolver): + def __init__(self, *args) -> None: + logger.debug(f"Creating {__class__.__name__}") + super().__init__(*args) + + def setup(self) -> None: + pass + + @QSlot(int) + def setStrength(self, strength: int) -> None: + logger.debug(f"strength was changed to {strength}") + config.set(f"{self._configKey}.solver.strength", strength) + self._loadConfig() + + def solve(self) -> None: logger.debug(f"Hello from solve() in {self.__class__.__name__}") + # TODO: Linear solver implementation -class MlatSolver(ISolver, QObject): - def __init__(self, config) -> None: - logger.debug(config) - super().__init__() +class MlatSolver(ISolver): + def __init__(self, *args) -> None: + logger.debug(f"Creating {__class__.__name__}") + super().__init__(*args) + + def setup(self) -> None: + self.mlatEngine = Engine() + + @QSlot(int) + def setStrength(self, strength: int) -> None: + logger.debug(f"strength was changed to {strength}") + config.set(f"{self._configKey}.solver.strength", strength) + self._loadConfig() + + def solve(self) -> None: + # logger.debug(f"Hello from solve() in {self.__class__.__name__}") + # time.sleep(0.05) # simulate very heavy calculation + # TODO: MLAT implementation + pass class SolverFactory: @staticmethod - def build_solver(solverType) -> \ + def fromType(solverType: SolverType) -> \ type[LinearSolver] | type[MlatSolver] | None: match solverType: case SolverType.LINEAR: @@ -51,21 +98,5 @@ def build_solver(solverType) -> \ return MlatSolver -class SolverRunner(QObject): - def __init__(self, config) -> None: - """This object is running inside the thread and manages - all the solvers. - """ - logger.debug(f"Creating {__class__.__name__}") - super().__init__() - # logger.debug(config) - - self._solvers: list[type[LinearSolver] | type[MlatSolver]] = [] - - if __name__ == "__main__": - from GlobalConfig import config - solverClass = SolverFactory.build_solver(SolverType("MLat")) - if solverClass is not None: - solver = solverClass(config) - solver.solve() + print("There is no point running this file directly") diff --git a/server/modules/VrcConnector.py b/server/modules/VrcConnector.py index 1b21f90..665ee0f 100644 --- a/server/modules/VrcConnector.py +++ b/server/modules/VrcConnector.py @@ -93,7 +93,6 @@ def closeOscServer(self) -> None: def startOscSender(self) -> None: self._oscTx = SimpleUDPClient(self._oscTxIp, self._oscTxPort) - # self.sendOsc("/chatbox/input", ["test message"]) def closeOscSender(self) -> None: if hasattr(self, "_oscTx"): @@ -180,10 +179,12 @@ def send(self, path: str, values: ArgValue) -> None: def addToFilter(self, relativePath: str) -> None: if relativePath not in self.worker.dispatcher.matchTopics: self.worker.dispatcher.matchTopics.append(relativePath) + logger.debug(f"Added {relativePath} to vrc osc filter") def removeFromFilter(self, relativePath: str) -> None: if relativePath in self.worker.dispatcher.matchTopics: self.worker.dispatcher.matchTopics.remove(relativePath) + logger.debug(f"Removed {relativePath} from vrc osc filter") def _oscGeneralConfigChanged(self, root: str) -> None: if root.startswith("program."): @@ -237,7 +238,7 @@ def call_handlers_for_packet(self, data: bytes, try: packet = osc_packet.OscPacket(data) for msg in packet.messages: - if msg.message.address.startswith("/avatar/parameters/pat") \ + if msg.message.address.startswith("/avatar/parameters/") \ and msg.message.address[19:] in self.matchTopics: # pid = threadAsStr(QThread.currentThread()) # logger.debug( diff --git a/server/modules/__init__.py b/server/modules/__init__.py index 09620ed..3182cfd 100644 --- a/server/modules/__init__.py +++ b/server/modules/__init__.py @@ -5,9 +5,10 @@ config OptionAdapter """ - -from .AvatarPoint import AvatarPoint from .GlobalConfig import config + +from .AvatarPoint import AvatarPointSphere +from .ContactGroup import ContactGroup, ContactGroupManager from .HardwareDevice import HardwareDevice from .HwManager import HwManager from .Motor import Motor @@ -15,5 +16,5 @@ from .OscMessageTypes import * from .Points import Sphere3D from .Server import ServerSingleton -from .Solver import LinearSolver, MlatSolver, SolverRunner +from .Solver import LinearSolver, MlatSolver, SolverFactory from .VrcConnector import VrcConnectorImpl diff --git a/server/ui/ContactGroupSettings.py b/server/ui/ContactGroupSettings.py index 5110d24..28c82de 100644 --- a/server/ui/ContactGroupSettings.py +++ b/server/ui/ContactGroupSettings.py @@ -17,6 +17,7 @@ from ui.Delegates import FloatSpinBoxDelegate, IntSpinBoxDelegate from ui.uiHelpers import handleClosePrompt, handleDeletePrompt from utils import LoggerClass, PathReader +from utils.Enums import SolverType logger = LoggerClass.getSubLogger(__name__) @@ -28,7 +29,7 @@ def __init__(self, configKey: str, *args, **kwargs) -> None: logger.debug(f"Creating {__class__.__name__}") super().__init__(*args, **kwargs) - self._configKey = "groups.group" + configKey + self._configKey = configKey self.buildUi() @@ -253,6 +254,8 @@ def hasUnsavedOptions(self) -> bool: def saveOptions(self) -> None: """Save the options from this tab.""" config.set(self._configKey, self._data) + if self.motorsTableModel.settingsWereChanged: + config.configRootUpdateDone.emit(self._configKey) self.motorsTableModel.settingsWereChanged = False @@ -357,6 +360,8 @@ def hasUnsavedOptions(self) -> bool: def saveOptions(self) -> None: """Save the options from this tab.""" config.set(self._configKey, self._data) + if self.colliderPointsTableModel.settingsWereChanged: + config.configRootUpdateDone.emit(self._configKey) self.colliderPointsTableModel.settingsWereChanged = False @@ -384,10 +389,9 @@ def buildUi(self) -> None: # the solver name self.cb_solverType = QComboBox(self) - self.cb_solverType.addItem("Mlat") - self.cb_solverType.addItem("SimpleDistance") + for type in SolverType: + self.cb_solverType.addItem(type) self.cb_solverType.setObjectName("cb_solverType") - self.cb_solverType.setCurrentText("Mlat") self.cb_solverType.currentTextChanged.connect(self.changeSolver) self.addOpt("solverType", self.cb_solverType) self.selfLayout.addRow("Solver Type:", self.cb_solverType) @@ -427,6 +431,7 @@ def buildUi(self) -> None: self.selfLayout.addItem(self.spacer1) def changeSolver(self, selected: str) -> None: + # TODO: Refactor out and also use Enum self._currentSolver = selected for solver, uiElement in self._solverOptionMapping: if solver == self._currentSolver: diff --git a/server/ui/MainWindow.py b/server/ui/MainWindow.py index 26e5765..5c6eefb 100644 --- a/server/ui/MainWindow.py +++ b/server/ui/MainWindow.py @@ -12,7 +12,7 @@ QSpacerItem, QSplitter, QVBoxLayout, QWidget) import ui -from modules import HardwareDevice, ServerSingleton, config +from modules import HardwareDevice, ContactGroup, ServerSingleton, config from ui import EspSettingsDialog, ContactGroupSettings from utils import LoggerClass from utils import HardwareConnectionType @@ -28,12 +28,17 @@ def __init__(self, *args, **kwargs) -> None: self._singleWindows: dict[str, QWidget] = {} self._hwRows: dict[int, QWidget] = {} + self._cgRows: dict[int, QWidget] = {} self.setupUi() self.server = ServerSingleton.getInstance() self.server.hwManager.hwListChanged.connect( self._handleHwListChange) + self.server.contactGroupManager.contactGroupListChanged.connect( + self._handleCgListChange + ) self._pollHwList() + self._pollCgList() def setupUi(self) -> None: """Initialize the main UI.""" @@ -134,16 +139,6 @@ def setupUi(self) -> None: self.contactGroupScrollAreaWidgetContentLayout.setContentsMargins( 0, 0, 0, 0) - # TODO: dynamically add rows - self.testContactGroupInstance1 = ContactGroupRow("0", - self.contactGroupScrollAreaWidgetContent) - self.testContactGroupInstance2 = ContactGroupRow("0", - self.contactGroupScrollAreaWidgetContent) - self.contactGroupScrollAreaWidgetContentLayout.addWidget( - self.testContactGroupInstance1) - self.contactGroupScrollAreaWidgetContentLayout.addWidget( - self.testContactGroupInstance2) - # set hardware scroll areas only widget to our scrollarea content widget self.contactGroupScrollArea.setWidget( self.contactGroupScrollAreaWidgetContent) @@ -193,11 +188,11 @@ def closedSingleWindow(self, windowReference) -> None: logger.debug(self._singleWindows) def _pollHwList(self) -> None: - """Trigger initial device row loading after startup""" + """Trigger initial HardwareDevice row loading after startup""" self._handleHwListChange(self.server.hwManager.hardwareDevices) def _handleHwListChange(self, devices: dict[int, HardwareDevice]) -> None: - """Handle changed to the servers hardware list. + """Handle changes to the servers hardware list. This could be a new device beeing added or an existing one re-created. @@ -224,6 +219,38 @@ def _handleHwListChange(self, devices: dict[int, HardwareDevice]) -> None: self._hwRows[id] = newRow self._triggerSplitterResize() + def _pollCgList(self) -> None: + """Trigger initial ContactGroup row loading after startup""" + self._handleCgListChange(self.server.contactGroupManager.contactGroups) + + def _handleCgListChange(self, groups: dict[int, ContactGroup]) -> None: + """Handle changes to the ContactGroup list + + Args: + groups (list[ContactGroup]): The list of ContactGroups + """ + for id, group in groups.items(): + newRow = ContactGroupRow(group._configKey, + self.server.contactGroupManager.contactGroups[id], + self.contactGroupScrollAreaWidgetContent) + group.dataRxStateChanged.connect( + newRow.lb_groupHasIncomingData.setState) + newRow.hsld_strength.valueChanged.connect( + group.strengthSliderValueChanged) + # newRow.widgetExpanded.connect(self._triggerSplitterResize) + if id in self._cgRows.keys(): + # Row already exists, re-create + oldRow = self._cgRows[id] + self.contactGroupScrollAreaWidgetContentLayout.replaceWidget( + oldRow, newRow) + oldRow.deleteLater() + else: + # It's a new row, append it + self.contactGroupScrollAreaWidgetContentLayout.addWidget( + newRow) + self._cgRows[id] = newRow + self._triggerSplitterResize() + @QSlot() def _triggerSplitterResize(self): sizes = [0] * len(self.splitter.children()) @@ -236,6 +263,7 @@ def closeEvent(self, event: QCloseEvent) -> None: Args: event (QCloseEvent | None]): The qt event. """ + logger.debug("-----EXIT STARTED-----") logger.debug(f"closeEvent in {__class__.__name__}") logger.debug("Stopping server...") @@ -292,6 +320,8 @@ def _openSettingsWindow(self, self._settingsWindow = win(configKey) self._settingsWindow.destroyed.connect( self._closedSettingsWindow) + self._settingsWindow.setWindowModality( + Qt.WindowModality.ApplicationModal) self._settingsWindow.show() def _closedSettingsWindow(self): @@ -340,7 +370,6 @@ def __init__(self, configKey: str, deviceRef: HardwareDevice, parent: QWidget | None) -> None: self._configKey = configKey super().__init__(parent) - self._hwSettingsWindow: QWidget | None = None self._deviceRef = deviceRef self._updateStaticText() @@ -573,10 +602,14 @@ class ContactGroupRow(BaseRow): """A independent contact group row inside the scroll area.""" strengthSliderValueChanged = QSignal(int, int) - def __init__(self, configKey: str, parent: QWidget | None) -> None: + def __init__(self, configKey: str, contactGroupRef: ContactGroup, + parent: QWidget | None) -> None: # logger.debug(f"Creating {__class__.__name__}") self._configKey = configKey super().__init__(parent) + self._contactGroupRef = contactGroupRef + + self._updateStaticText() def buildUi(self) -> None: self.hl_groupTopRow = QHBoxLayout() @@ -586,14 +619,14 @@ def buildUi(self) -> None: font11.setPointSize(11) # the name label - self.lb_contactGroupName = ui.StaticLabel("Name: ", "Head", "", self) + self.lb_contactGroupName = ui.StaticLabel("Name: ", "", "", self) # self.lb_contactGroupName.setScaledContents(True) self.hl_groupTopRow.addWidget(self.lb_contactGroupName) # the vrc data label self.lb_groupHasIncomingData = ui.StatefulLabel( - ("VRC Data: \u274C", "VRC Data: \u2705"), self) - self.lb_groupHasIncomingData.setState() + ("VRC Data: \u274C", "VRC Data: \u2705", "VRC Data: \u231B"), self) + self.lb_groupHasIncomingData.setState(2) self.hl_groupTopRow.addWidget(self.lb_groupHasIncomingData) @@ -603,13 +636,12 @@ def buildUi(self) -> None: self.hsld_strength.setMinimum(0) self.hsld_strength.setMaximum(100) self.hsld_strength.setTracking(True) - self.hsld_strength.valueChanged.connect(self._sliderValueChanged) self.hl_groupTopRow.addWidget(self.hsld_strength) # the strength number self.lb_strength = ui.StaticLabel("Strength: ", "-", "%") + self.hsld_strength.valueChanged.connect(self.lb_strength.setNum) self.hl_groupTopRow.addWidget(self.lb_strength) - # TODO: This label needs updating # spacer self.spc_groupRow_1 = QSpacerItem( @@ -622,8 +654,7 @@ def buildUi(self) -> None: self.bt_groupExpand.setMaximumWidth(40) self.bt_groupExpand.setFont(font11) self.bt_groupExpand.setToolTip("Expand") - self.bt_groupExpand.toggledOn.connect( - lambda: self.createExpandingWidget(ContactGroupPointsWidget(self))) + self.bt_groupExpand.toggledOn.connect(self._openExpandingWidget) self.bt_groupExpand.toggledOff.connect( self._deleteExpandingWidget) self.hl_groupTopRow.addWidget(self.bt_groupExpand) @@ -635,6 +666,7 @@ def buildUi(self) -> None: self.bt_openVisualizer.setToolTip("Open visualizer") self.bt_openVisualizer.setText("\ud83d\udcc8") self.hl_groupTopRow.addWidget(self.bt_openVisualizer) + # TODO # button to open the group settings dialog self.bt_openGroupSettings = QPushButton(self) @@ -649,16 +681,19 @@ def buildUi(self) -> None: self.selfLayout.addLayout(self.hl_groupTopRow) - @QSlot(int) - def _sliderValueChanged(self, value: int) -> None: - """Add the group id to the slider value change event. + def _updateStaticText(self) -> None: + name = config.get(f"{self._configKey}.name", "") + self.lb_contactGroupName.setText(name) + strength = config.get(f"{self._configKey}.solver.strength", 69) + self.hsld_strength.setValue(strength) + self.lb_strength.setNum(strength) - Args: - value (int): The sliders percentage value. + def _openExpandingWidget(self) -> None: + """Create the expanding widget and initialize it. """ - # TODO: Add group id - # TODO: WE MIGHT NOT EVEN NEED THIS!! - self.strengthSliderValueChanged.emit("group id", value) + widget = ContactGroupPointsWidget(self) + widget.connect(self._contactGroupRef) + self.createExpandingWidget(widget) class ContactGroupPointsWidget(QWidget): @@ -686,10 +721,16 @@ def buildUi(self) -> None: self.lb_pointsRow.setText("Points:") self.selfLayout.addWidget(self.lb_pointsRow) - # TODO: this will later be dynamic, just for testing now - self.rows = [] - for id in range(5): - row = PointDetailsRow(id) + def connect(self, contactGroup: ContactGroup) -> None: + """Connect contact group row with expanded row. + + Args: + contactGroup (ContactGroup): The contact group reference + """ + self.rows: list[PointDetailsRow] = [] + for motor in contactGroup.motors: + row = PointDetailsRow(motor._name) + motor.speedChanged.connect(row.updateValue) self.selfLayout.addLayout(row) self.rows.append(row) @@ -704,22 +745,22 @@ def closeEvent(self, event: QCloseEvent) -> None: class PointDetailsRow(ExpandedWidgetDataRowBase): - def __init__(self, rowId: int, *args, **kwargs) -> None: + def __init__(self, name: str, *args, **kwargs) -> None: """Initialize PointDetailsRow.""" logger.debug(f"Creating {__class__.__name__}") - self.rowId = rowId + self._name = name super().__init__(*args, **kwargs) def buildUi(self) -> None: self.lb_groupPointName = ui.StaticLabel( - "Name: ", str(self.rowId), parent=self.parent()) + "Name: ", self._name, parent=self.parent()) self.addWidget(self.lb_groupPointName, 0, Qt.AlignmentFlag.AlignLeft) self.lb_groupPointValue = ui.StaticLabel( - "Value: ", "0.2", parent=self.parent()) + "Value: ", "-", parent=self.parent()) self.addWidget(self.lb_groupPointValue, 0, Qt.AlignmentFlag.AlignLeft) self.addStretch(1) - def updateValue(self, value: float) -> None: + def updateValue(self, a1: int, a2: int, value: float) -> None: self.lb_groupPointValue.setFloat(value)