diff --git a/.gitignore b/.gitignore index 53e6530fd..7e56fad77 100644 --- a/.gitignore +++ b/.gitignore @@ -452,6 +452,7 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +.codspeed/ # Translations *.mo diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b83405764..e03886b78 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,14 +31,14 @@ repos: exclude: 'helpers.py' - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.13.0 + rev: v1.14.1 hooks: - id: mypy exclude: '/qt_gui\.py$|/qtgui_rc\.py$|tests/|generate_ver_file\.py$' additional_dependencies: [types-cffi, types-Pillow, types-psutil, types-pyinstaller, types-PyYAML, types-requests, lxml] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.3 + rev: v0.8.6 hooks: - id: ruff exclude: '/qtgui_rc.py$|tests/' @@ -51,7 +51,7 @@ repos: exclude: '/qtgui_rc.py$|tests/' - repo: https://github.com/asottile/pyupgrade - rev: v3.19.0 + rev: v3.19.1 hooks: - id: pyupgrade exclude: '/qtgui_rc.py$' diff --git a/CHANGELOG.md b/CHANGELOG.md index e4fef4cb1..9dcabc739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## 3.6.2 +* Initial support colors for G19 - @emcek * Internal: * Improve checking for new releases (pydantic model) - @emcek + * Only one branch stable version of DCS is preferred for new installs - @emcek * Make Nuitka with Python 3.13 default when building executable - @emcek ## 3.6.1 diff --git a/docs/requirements.txt b/docs/requirements.txt index 1d324d117..4648894c9 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -6,5 +6,5 @@ mkdocs-material==9.5.49 mkdocs-plantuml==0.1.1 mkdocs-section-index==0.3.9 mkdocstrings==0.27.0 -mkdocstrings-python==1.12.2 +mkdocstrings-python==1.13.0 plantuml==0.3.0 diff --git a/pyproject.toml b/pyproject.toml index db089b527..07e97d96f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,12 +41,12 @@ dynamic = ['version'] dependencies = [ 'cffi==1.17.1', 'eval-type-backport==0.2.0 ; python_version < "3.10"', - 'gitpython==3.1.43', + 'gitpython==3.1.44', 'lupapy==2.2', 'packaging==24.2', - 'pillow==11.0.0', - 'psutil==6.1.0', - 'pydantic==2.10.3', + 'pillow==11.1.0', + 'psutil==6.1.1', + 'pydantic==2.10.4', 'pyside6==6.8.1', 'pyyaml==6.0.2', 'requests==2.32.3', @@ -71,7 +71,7 @@ test = [ 'interrogate==1.7.0', 'isort==5.13.2', 'lxml==5.3.0', - 'mypy==1.13.0', + 'mypy==1.14.1', 'pip-audit==2.7.3', 'pycodestyle==2.12.1', 'pytest==8.3.4', @@ -79,12 +79,12 @@ test = [ 'pytest-cov==6.0.0', 'pytest-qt==4.4.0 ; sys_platform == "win32"', 'pytest-randomly==3.16.0', - 'ruff==0.8.3', - 'types-cffi==1.16.0.20240331', + 'ruff==0.8.6', + 'types-cffi==1.16.0.20241221', 'types-pillow==10.2.0.20240822', - 'types-psutil==6.1.0.20241102', + 'types-psutil==6.1.0.20241221', 'types-pyinstaller==6.11.0.20241028', - 'types-pyyaml==6.0.12.20240917', + 'types-pyyaml==6.0.12.20241230', 'types-requests==2.32.0.20241016', ] docs = [ @@ -96,7 +96,7 @@ docs = [ 'mkdocs-plantuml==0.1.1', 'mkdocs-section-index==0.3.9', 'mkdocstrings==0.27.0', - 'mkdocstrings-python==1.12.2', + 'mkdocstrings-python==1.13.0', 'plantuml==0.3.0', ] diff --git a/requirements.txt b/requirements.txt index aa3871cec..e05eece5e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,11 @@ cffi==1.17.1 eval-type-backport==0.2.0; python_version < '3.10' -GitPython==3.1.43 +GitPython==3.1.44 lupapy==2.2 packaging==24.2 -pillow==11.0.0 -psutil==6.1.0 -pydantic==2.10.3 +pillow==11.1.0 +psutil==6.1.1 +pydantic==2.10.4 PySide6==6.8.1 PyYAML==6.0.2 requests==2.32.3 diff --git a/requirements_docs.txt b/requirements_docs.txt index fd568f89a..458876bef 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -7,5 +7,5 @@ mkdocs-material==9.5.49 mkdocs-plantuml==0.1.1 mkdocs-section-index==0.3.9 mkdocstrings==0.27.0 -mkdocstrings-python==1.12.2 +mkdocstrings-python==1.13.0 plantuml==0.3.0 diff --git a/requirements_test.txt b/requirements_test.txt index d04726242..20f0a7541 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,7 +3,7 @@ flake8==7.1.1 interrogate==1.7.0 isort==5.13.2 lxml==5.3.0 -mypy==1.13.0 +mypy==1.14.1 pip-audit==2.7.3 pycodestyle==2.12.1 pytest==8.3.4 @@ -11,10 +11,10 @@ pytest-codspeed==3.1.0 pytest-cov==6.0.0 pytest-qt==4.4.0; sys_platform == "win32" pytest-randomly==3.16.0 -ruff==0.8.3 -types-cffi==1.16.0.20240331 +ruff==0.8.6 +types-cffi==1.16.0.20241221 types-Pillow==10.2.0.20240822 -types-psutil==6.1.0.20241102 +types-psutil==6.1.0.20241221 types-pyinstaller==6.11.0.20241028 -types-PyYAML==6.0.12.20240917 +types-PyYAML==6.0.12.20241230 types-requests==2.32.0.20241016 diff --git a/src/dcspy/__init__.py b/src/dcspy/__init__.py index 0fea87b46..2f96e0e09 100644 --- a/src/dcspy/__init__.py +++ b/src/dcspy/__init__.py @@ -26,8 +26,8 @@ LOG.debug(f'{uname()}') LOG.debug(f'Configuration: {_config} from: {default_yaml}') LOG.info(f'dcspy {__version__} https://github.com/{DCSPY_REPO_NAME}') -dcs_type, dcs_ver = check_dcs_ver(Path(str(_config['dcs']))) -LOG.info(f'DCS {dcs_type} ver: {dcs_ver}') +dcs_ver = check_dcs_ver(Path(str(_config['dcs']))) +LOG.info(f'DCS ver: {dcs_ver}') def get_config_yaml_item(key: str, /, default: ConfigValue | None = None) -> ConfigValue: diff --git a/src/dcspy/logitech.py b/src/dcspy/logitech.py index 0e1f437c1..17ce49829 100644 --- a/src/dcspy/logitech.py +++ b/src/dcspy/logitech.py @@ -1,3 +1,4 @@ +from copy import copy from functools import partial from importlib import import_module from logging import getLogger @@ -10,9 +11,10 @@ from dcspy import dcsbios, get_config_yaml_item from dcspy.aircraft import BasicAircraft, MetaAircraft -from dcspy.models import KEY_DOWN, SEND_ADDR, SUPPORTED_CRAFTS, TIME_BETWEEN_REQUESTS, AnyButton, Gkey, LcdButton, LcdType, LogitechDeviceModel, MouseButton +from dcspy.models import (KEY_DOWN, SEND_ADDR, SUPPORTED_CRAFTS, TIME_BETWEEN_REQUESTS, AnyButton, Color, Gkey, LcdButton, LcdType, LogitechDeviceModel, + MouseButton) from dcspy.sdk import key_sdk, lcd_sdk -from dcspy.utils import get_full_bios_for_plane, get_planes_list +from dcspy.utils import get_full_bios_for_plane, get_planes_list, rgba LOG = getLogger(__name__) @@ -35,7 +37,7 @@ def __init__(self, parser: dcsbios.ProtocolParser, sock: socket, model: Logitech self.bios_name = '' self.plane_detected = False self.lcdbutton_pressed = False - self._display: list[str] = [] + self._text: list[tuple[str, Color]] = [] self.model = model self.lcd_sdk = lcd_sdk.LcdSdkManager(name='DCS World', lcd_type=self.model.lcd_info.type) self.key_sdk = key_sdk.GkeySdkManager(self.gkey_callback_handler) @@ -44,37 +46,46 @@ def __init__(self, parser: dcsbios.ProtocolParser, sock: socket, model: Logitech self.plane = BasicAircraft(self.model.lcd_info) @property - def display(self) -> list[str]: + def text(self) -> list[tuple[str, Color]]: """ Get the latest text from LCD. :return: List of strings with data, row by row """ - return self._display + return self._text - @display.setter - def display(self, message: list[str]) -> None: + @text.setter + def text(self, message: list[tuple[str, Color]]) -> None: """ - Display a message as an image at LCD. + Display text message at LCD. - For G13/G15/G510 takes the first four (4) or fewer elements of a list and display as four (4) rows. - For G19 takes the first eight (8) or fewer elements of the list and display as eight (8) rows. - :param message: List of strings to display, row by row. + First element is title - used only for G19 + For G13/G15/G510 takes elements two (2) to four (4). + For G19 takes the elements two (2) to eight (8). + :param message: List of tuples with strings and color to display, row by row. """ - self._display = message + self._text = message if self.model.lcd_info.type != LcdType.NONE: - self.lcd_sdk.update_display(self._prepare_image()) + self.lcd_sdk.update_text(copy(message)) - def text(self, message: list[str]) -> None: + @property + def messages(self) -> list[str]: """ - Display message at LCD. + Get the text massages without tittle from LCD. - For G13/G15/G510 takes the first four (4) or fewer elements of the list and display as four (4) rows. + :return: List of strings with data, row by row + """ + return [pair[0] for pair in self._text][1:] + + def display(self) -> None: + """ + Display a message as an image at LCD. + + For G13/G15/G510 takes the first four (4) or fewer elements of a list and display as four (4) rows. For G19 takes the first eight (8) or fewer elements of the list and display as eight (8) rows. - :param message: List of strings to display, row by row. """ if self.model.lcd_info.type != LcdType.NONE: - self.lcd_sdk.update_text(message) + self.lcd_sdk.update_display(self._prepare_image()) def detecting_plane(self, value: str) -> None: """ @@ -88,16 +99,16 @@ def detecting_plane(self, value: str) -> None: planes_list = get_planes_list(bios_dir=Path(get_config_yaml_item('dcsbios'))) if self.plane_name in SUPPORTED_CRAFTS: LOG.info(f'Advanced supported aircraft: {value}') - self.display = ['Detected aircraft:', SUPPORTED_CRAFTS[self.plane_name]['name']] + self.text = [(' DCSpy ', Color.orange), ('Detected aircraft:', Color.white), (SUPPORTED_CRAFTS[self.plane_name]['name'], Color.green)] self.plane_detected = True elif self.plane_name not in SUPPORTED_CRAFTS and value in planes_list: LOG.info(f'Basic supported aircraft: {value}') self.bios_name = value - self.display = ['Detected aircraft:', value] + self.text = [(' DCSpy ', Color.orange), ('Detected aircraft:', Color.white), (value, Color.green)] self.plane_detected = True elif value not in planes_list: LOG.warning(f'Not supported aircraft: {value}') - self.display = ['Detected aircraft:', value, 'Not supported yet!'] + self.text = [(' DCSpy ', Color.orange), ('Detected aircraft:', Color.white), (value, Color.green), ('Not supported yet!', Color.red)] def unload_old_plane(self) -> None: """Unloads the previous plane by remove all callbacks and keep only one.""" @@ -214,8 +225,9 @@ def _prepare_image(self) -> Image.Image: img = Image.new(mode=self.model.lcd_info.mode.value, color=self.model.lcd_info.background, size=(self.model.lcd_info.width.value, self.model.lcd_info.height.value)) draw = ImageDraw.Draw(img) - for line_no, line in enumerate(self._display): - draw.text(xy=(0, self.model.lcd_info.line_spacing * line_no), text=line, fill=self.model.lcd_info.foreground, font=self.model.lcd_info.font_s) + for line_no, txt_and_color in enumerate(self._text[1:]): + draw.text(xy=(0, self.model.lcd_info.line_spacing * line_no), text=txt_and_color[0], + fill=rgba(txt_and_color[1], mode=self.model.lcd_info.mode), font=self.model.lcd_info.font_s) # type: ignore[arg-type] return img def __str__(self) -> str: diff --git a/src/dcspy/models.py b/src/dcspy/models.py index 9c806414c..14cc24aba 100644 --- a/src/dcspy/models.py +++ b/src/dcspy/models.py @@ -494,9 +494,13 @@ class CycleButton(BaseModel): @classmethod def from_request(cls, /, req: str) -> CycleButton: """ - Use BIOS request string from plane configuration YAML. + Convert a request string to a `CycleButton` instance by extracting the necessary details from the request's components. - :param req: BIOS request string + The request is expected to follow a predefined structure where its components + are separated by spaces. + + :param req: A string request expected to contain `control_name`, an underscore, `step`, and `max_value`, separated by spaces. + :return: Instance of `CycleButton` based on extracted data. """ selector, _, step, max_value = req.split(' ') return CycleButton(ctrl_name=selector, step=int(step), max_value=int(max_value)) @@ -507,7 +511,15 @@ def __bool__(self) -> bool: class GuiPlaneInputRequest(BaseModel): - """Input request for Control for GUI.""" + """ + Represents a GUI plane input request. + + This class is used to construct and manage input requests originating from + a graphical interface, such as radio buttons or other control widgets, + that interact with plane systems. + It allows for structured generation of requests based on provided parameters or + configurations, and provides utility methods to convert data into request objects. + """ identifier: str request: str widget_iface: str @@ -515,12 +527,16 @@ class GuiPlaneInputRequest(BaseModel): @classmethod def from_control_key(cls, ctrl_key: ControlKeyData, rb_iface: str, custom_value: str = '') -> GuiPlaneInputRequest: """ - Generate GuiPlaneInputRequest from ControlKeyData and radio button widget. + Create an instance of GuiPlaneInputRequest based on provided control key data, a request type and optional custom value. + + The method generates a request string for the GUI widget interface determined by the specified request type (rb_iface) + using information from the ControlKeyData object (ctrl_key). + If a custom value is provided, it incorporates the value into the generated request for certain request types. - :param ctrl_key: ControlKeyData - :param rb_iface: widget interface - :param custom_value: custom request - :return: GuiPlaneInputRequest + :param ctrl_key: A ControlKeyData object used to specify the control key's attributes, such as its name, suggested step, and maximum value. + :param rb_iface: A string that represents the requested widget interface type, options include types such as 'rb_action', 'rb_fixed_step_inc', etc. + :param custom_value: An optional string used to provide a custom value for specific request types ('rb_custom' or 'rb_set_state'). + :return: A GuiPlaneInputRequest object initialized with the identifier, generated request string, and the specified widget interface type. """ rb_iface_request = { 'rb_action': f'{ctrl_key.name} TOGGLE', @@ -538,10 +554,15 @@ def from_control_key(cls, ctrl_key: ControlKeyData, rb_iface: str, custom_value: @classmethod def from_plane_gkeys(cls, /, plane_gkeys: dict[str, str]) -> dict[str, GuiPlaneInputRequest]: """ - Generate GuiPlaneInputRequest from plane_gkeys yaml. + Create a dictionary mapping unique plane keys to `GuiPlaneInputRequest` objects, based on input configuration data. - :param plane_gkeys: - :return: + The method processes each key-value pair where the value contains a request type and determines the appropriate widget + interface based on specified keywords. + A mapping dictionary is used to identify widget interfaces corresponding to request types. + + :param plane_gkeys: A dictionary where each key is a plane identifier (string) and the value is + a space-separated string of configuration data that includes a request type. + :return: A dictionary mapping each plane identifier (string) to a `GuiPlaneInputRequest` instance. """ input_reqs = {} req_keyword_rb_iface = { @@ -567,7 +588,11 @@ def from_plane_gkeys(cls, /, plane_gkeys: dict[str, str]) -> dict[str, GuiPlaneI @classmethod def make_empty(cls) -> GuiPlaneInputRequest: - """Make empty GuiPlaneInputRequest.""" + """ + Create an empty GuiPlaneInputRequest object with default values assigned to its attributes. + + :return: An instance of GuiPlaneInputRequest with default empty values + """ return cls(identifier='', request='', widget_iface='') @@ -599,7 +624,14 @@ def __str__(self) -> str: class MouseButton(BaseModel): - """LCD Buttons.""" + """ + Representation of a mouse button. + + Provides functionality for working with mouse buttons, including conversion + to string, boolean evaluation, hashing, and constructing instances from YAML + strings. + Supports generating sequences of mouse buttons within a specified range. + """ button: int = 0 def __str__(self) -> str: @@ -616,10 +648,16 @@ def __hash__(self) -> int: @classmethod def from_yaml(cls, /, yaml_str: str) -> MouseButton: """ - Construct MouseButton from YAML string. + Create a MouseButton object from a YAML string representation. - :param yaml_str: MouseButton string, example: M_3 - :return: MouseButton instance + This method parses a given YAML string to extract the button number + encoded in the format `M_` (such as `M_1`, `M_2`, etc.) and generates + a MouseButton instance for the corresponding button. + If the format does not conform to expectations and parsing fails, a ValueError is raised. + + :param yaml_str: The YAML string representing the mouse button in the format `M_`. + :return: A MouseButton instance derived from the specified YAML string. + :raises ValueError: If the provided YAML string does not match the expected format `M_`. """ match = search(r'M_(\d+)', yaml_str) if match: @@ -629,9 +667,13 @@ def from_yaml(cls, /, yaml_str: str) -> MouseButton: @staticmethod def generate(button_range: tuple[int, int]) -> Sequence[MouseButton]: """ - Generate a sequence of MouseButton-Keys. + Generate a sequence of MouseButton objects based on the provided range. + + This utility creates MouseButton instances for each integer value within + the inclusive range defined by the ``button_range`` tuple. - :param button_range: Start and stop (inclusive) of range for mouse buttons + :param button_range: A tuple of two integers, representing the start and end of the range (inclusive) for generating MouseButton objects. + :return: A tuple containing instantiated MouseButton objects for each value in the specified range. """ return tuple([MouseButton(button=m) for m in range(button_range[0], button_range[1] + 1)]) @@ -908,13 +950,17 @@ def lcd_name(self) -> str: def _try_key_instance(klass: type[Gkey] | type[LcdButton] | type[MouseButton], method: str, key_str: str) -> AnyButton | None: """ - Detect key string could be parsed with method. + Attempt to invoke a method on a class with a given key string. - :param klass: Class of the key instance to try the method on - :param method: Name of the method to try with the key class. - :param key_str: A string representation of the key - :return: The result of calling the method on the key instance, or None if an error occurs. + The method will first attempt to call the provided method with the `key_str` as a parameter. + If there is a TypeError (indicating the method does not support a parameter), it attempts to call + the method without arguments. + If the method is missing or the call fails due to a ValueError or AttributeError, the function returns None. + :param klass: The class type on which the method is to be invoked. + :param method: The name of the method to call on the class. + :param key_str: A string key to be passed as a parameter to the method, if supported. + :return: An instance of `AnyButton` from the invoked method, if successful, otherwise None. """ try: return getattr(klass, method)(key_str) @@ -926,10 +972,14 @@ def _try_key_instance(klass: type[Gkey] | type[LcdButton] | type[MouseButton], m def get_key_instance(key_str: str) -> AnyButton: """ - Get key instance from string. + Resolve the provided key string into an instance of a valid key class based on a predefined set of classes and their respective resolution methods. - :param key_str: Key name from YAML configuration - :return: LcdButton, Gkey or MouseButton instance + If the key string matches a class method's criteria, it returns the resolved key instance. + If no match is found, an exception is raised. + + :param key_str: A string representing the name or identifier of the key to be resolved into a key instance (e.g., Gkey, LcdButton, or MouseButton). + :return: An instance of a class (AnyButton) that corresponds to the provided key string, if successfully resolved. + :raises AttributeError: If the provided key string cannot be resolved into a valid key instance using the predefined classes and methods. """ for klass, method in [(Gkey, 'from_yaml'), (MouseButton, 'from_yaml'), (LcdButton, key_str)]: key_instance = _try_key_instance(klass=klass, method=method, key_str=key_str) @@ -954,7 +1004,6 @@ class SystemData(BaseModel): release: str ver: str proc: str - dcs_type: str dcs_ver: str dcspy_ver: str bios_ver: str @@ -963,7 +1012,11 @@ class SystemData(BaseModel): @property def sha(self) -> str: - """Get SHA from DCS_BIOS repo.""" + """ + Provides a property to retrieve the SHA part of the DCS-BIOS repo. + + :return: The extracted SHA value from the `dcs_bios_ver` string. + """ return self.dcs_bios_ver.split(' ')[0] @@ -978,16 +1031,25 @@ class Direction(Enum): class ZigZagIterator: - """Iterate with values from zero (0) to max_val and back.""" + """ + An iterator that moves within a range in an oscillating pattern. + + The iterator starts at a given current value, progresses or retreats based on the defined step size + and changes a direction upon reaching the boundaries of the range (`max_val` and 0). + This allows for oscillating behavior within the specified limits. + The class also provides access to its current direction of iteration. + """ def __init__(self, current: int, max_val: int, step: int = 1) -> None: """ - Initialize with current and max value. + Represent a simple iterator with a defined range and step increment. - A default direction is towards max_val. + The iterator maintains a current value, a maximum limit, and adjusts + its progression based on the specified step. + It also tracks the direction of iteration internally. - :param current: Current value - :param max_val: Maximum value - :param step: Step size, 1 by default + :param current: The starting point of the iterator. + :param max_val: The upper limit of the iterator range. + :param step: The increment value for each iteration, defaults to 1. """ self.current = current self.step = step @@ -1014,21 +1076,33 @@ def __next__(self) -> int: @property def direction(self) -> Direction: - """Return direction.""" + """ + Represent the direction of an iterator or entity within a defined context. + + This property retrieves the current direction of the iterator. + + :return: The current direction of the iterator. + """ return self._direction @direction.setter def direction(self, value: Direction) -> None: """ - Set direction. + Set the direction of the current instance. - :param value: `Direction.FORWARD` or `Direction.BACKWARD` + :param value: The new direction to assign to the instance. """ self._direction = value class Asset(BaseModel): - """GitHub Release Asset model.""" + """ + Representation of an asset with metadata information. + + This class is used to encapsulate details about an asset such as its + URL, name, label, content type, size, and download location. + It also provides functionality to validate the asset's properties against specific criteria. + """ url: str name: str label: str @@ -1038,11 +1112,13 @@ class Asset(BaseModel): def correct_asset(self, extension: str = '', file_name: str = '') -> bool: """ - Check if asset meet criteria. + Determine whether the asset's name matches the specified file extension and contains the given file name. + + This method checks if the name of the asset ends with the provided file extension and if the given file name is a substring of the asset's name. - :param extension: File extension - :param file_name: File name - :return: True if asset met requirements, False otherwise + :param extension: The file extension to check for. + :param file_name: The specific file name to look for within the asset's name. + :return: True if the asset's name ends with the given extension and contains the specified file name, False otherwise. """ result = False if self.name.endswith(extension) and file_name in self.name: @@ -1051,7 +1127,14 @@ def correct_asset(self, extension: str = '', file_name: str = '') -> bool: class Release(BaseModel): - """GitHub Release model.""" + """ + Representation of a software release. + + The Release class provides detailed information about a specific release of a software project, + including metadata such as URLs, tags, names, and dates. + It also includes functionality to determine whether a release is the latest and to + retrieve downloadable assets. + """ url: str html_url: str tag_name: str @@ -1065,10 +1148,13 @@ class Release(BaseModel): def is_latest(self, current_ver: str | version.Version) -> bool: """ - Check if a release is latest. + Determine if the provided version is the latest compared to the instance's version. - :param current_ver: String or Version object - :return: True if the current version is latest, False otherwise + This method compares the version of the current object with a given version to check + if the current version is equal to or earlier than the given version. + + :param current_ver: The version to compare against, it can be provided as a string or as a version.Version object. + :return: Returns True if the current version is less than or equal to the provided version (indicating it is the latest), False otherwise. """ if isinstance(current_ver, str): current_ver = version.parse(current_ver) @@ -1076,11 +1162,15 @@ def is_latest(self, current_ver: str | version.Version) -> bool: def download_url(self, extension: str = '', file_name: str = '') -> str: """ - Get downloadable URL for asset with extension and file name. + Download the URL of a specific asset that matches the given file name and extension. + + This method iterates through the list of assets, applying the criteria specified by + the `extension` and `file_name` parameters to identify the correct asset. + If no asset matches the provided criteria, an empty string is returned. - :param extension: File extension - :param file_name: String in file name - :return: downloadable URL + :param extension: The file extension to search for, defaults to an empty string if not specified. + :param file_name: The file name to search for, defaults to an empty string if not specified. + :return: The download URL of the asset if a match is found, otherwise an empty string. """ try: dl_url = next(asset.browser_download_url for asset in self.assets if asset.correct_asset(extension=extension, file_name=file_name)) @@ -1091,18 +1181,20 @@ def download_url(self, extension: str = '', file_name: str = '') -> str: @property def version(self) -> version.Version: """ - Get version. + The `version` property retrieves the software version as a `version.Version` object. - :return: Version object for git tag + The version data is parsed from the `tag_name` attribute, which is expected to be in a format compatible with `packaging.version`. + + :return: Parsed `Version` object representing the software version. """ return version.parse(self.tag_name) @property def published(self) -> str: """ - Get published date. + Convert and format the `published_at` attribute into a human-readable date string in the format 'DD Month YYYY'. - :return: Date as string + :return: The formatted publication date string. """ published = datetime.strptime(self.published_at, '%Y-%m-%dT%H:%M:%S%z').strftime('%d %B %Y') return str(published) @@ -1119,8 +1211,14 @@ class RequestType(Enum): class RequestModel(BaseModel): - """Abstract request representation with common interface to send requests via UDE socket.""" + """ + Represent a request model for handling different input button states and their respective BIOS actions. + This class is designed to manage various types of input requests, including cycle, custom, + and push-button requests. + It provides functionality to validate input data, generate requests in byte format, and interpret requests based on specific conditions. + It also supports creating empty request models and handling interactions with BIOS configuration via designated callable functions. + """ ctrl_name: str raw_request: str get_bios_fn: Callable[[str], BiosValue] @@ -1130,10 +1228,13 @@ class RequestModel(BaseModel): @field_validator('ctrl_name') def validate_interface(cls, value: str) -> str: """ - Validate. + Validate the provided interface name ensuring it consists only of uppercase letters, digits, or underscores. - :param value: - :return: + This validator enforces strict naming conventions for control names, rejecting any value that contains invalid characters or is an empty string. + + :param value: The interface name to validate. + :return: The validated interface name if it passes all checks. + :raises ValueError: If the given value is an empty string or contains characters other than uppercase letters (A-Z), digits (0-9), or underscores (_). """ if not value or not all(ch.isupper() or ch == '_' or ch.isdigit() for ch in value): raise ValueError("Invalid value for 'ctrl_name'. Only A-Z, 0-9 and _ are allowed.") @@ -1142,14 +1243,18 @@ def validate_interface(cls, value: str) -> str: @classmethod def from_request(cls, key: AnyButton, request: str, get_bios_fn: Callable[[str], BiosValue]) -> RequestModel: """ - Build an object based on string request. + Create an instance of the RequestModel class using a specific request string. - For cycle request `get_bios_fn` is used to update a current value of BIOS selector. + This method processes the provided request string to extract necessary + information, such as control name and cycle details. + It initializes a CycleButton instance using the request information if applicable. + The function then returns a RequestModel instance populated with the parsed data and additional state information. - :param key: LcdButton, Gkey or MouseButton - :param request: The raw request string. - :param get_bios_fn: A callable function that returns a current value for BIOS selector. - :return: An instance of the RequestModel class. + :param key: The key representing the `AnyButton` instance tied to the request. + :param request: The raw request string providing all request details. + :param get_bios_fn: A callable function that retrieves BIOS values, function takes + a string input (BIOS key) and returns a corresponding `BiosValue` object. + :return: A new instance of `RequestModel` populated with data parsed from the provided request string and supporting parameters. """ cycle_button = CycleButton(ctrl_name='', step=0, max_value=0) if RequestType.CYCLE.value in request: @@ -1160,15 +1265,23 @@ def from_request(cls, key: AnyButton, request: str, get_bios_fn: Callable[[str], @classmethod def empty(cls, key: AnyButton) -> RequestModel: """ - Create an empty request model, for a key which isn't assign. + Create an empty instance of RequestModel with default values for its attributes. - :param key: LcdButton, Gkey or MouseButton - :return: The created request model. + :param key: Represents the key parameter, which will be used as a button object type for the RequestModel instance. + :return: A new instance of RequestModel initialized with default attribute values and the provided key parameter. """ return RequestModel(ctrl_name='EMPTY', raw_request='', get_bios_fn=int, cycle=CycleButton(ctrl_name='', step=0, max_value=0), key=key) def _get_next_value_for_button(self) -> int: - """Get next an integer value (cycle fore and back) for ctrl_name BIOS selector.""" + """ + Determine the next value for the button using a ZigZagIterator. + + If the cycle iterator is not already an instance of ZigZagIterator, it initializes one + using the control name and cycle attributes, before returning the next value from the iterator. + + :raises TypeError: If ``self.cycle.iter`` is not of the expected type and cannot be initialized properly as a ZigZagIterator instance. + :returns: The next integer value generated by the ZigZagIterator. + """ if not isinstance(self.cycle.iter, ZigZagIterator): self.cycle.iter = ZigZagIterator(current=int(self.get_bios_fn(self.ctrl_name)), step=self.cycle.step, @@ -1177,42 +1290,71 @@ def _get_next_value_for_button(self) -> int: @property def is_cycle(self) -> bool: - """Return True if cycle request, False otherwise.""" + """ + Check if the instance has a valid cycle. + + This property checks the internal state of the instance to determine whether a valid cycle exists. + A cycle is represented by the presence of a truthy value in the `cycle` attribute. + + :return: Returns ``True`` if a valid cycle exists, otherwise ``False``. + """ return bool(self.cycle) @property def is_custom(self) -> bool: - """Return True if custom request, False otherwise.""" + """ + Check if the request is of type custom. + + This property evaluates whether the raw_request attribute of the object contains + a custom request type, based on the predefined `RequestType.CUSTOM` value. + + :return: Boolean indicating if the request is of type custom. + """ return RequestType.CUSTOM.value in self.raw_request @property def is_push_button(self) -> bool: - """Return True if push button request, False otherwise.""" + """ + Identify if the request is a push-button type. + + This property checks if the raw_request contains a specific value indicating a push-button request type + and returns a boolean result accordingly. + + :return: True if the request is of type push-button, else False + """ return RequestType.PUSH_BUTTON.value in self.raw_request def bytes_requests(self, key_down: int | None = None) -> list[bytes]: """ - Generate a list of bytes that represent the individual requests based on the current state of the model. + Generate and returns a list of byte strings based on a specific request input. - :param key_down: One (1) indicates when G-Key was pushed down and zero (0) when G-Key is up - :return: a list of bytes representing the individual requests + The method generates a string request using the provided argument `key_down`. + It then splits the generated string request using the `|` delimiter and converts each segment into a byte string. + + :param key_down: Accepts an integer representing the key value or None for default behavior. + :return: A list containing byte strings derived from the generated request. """ request = self._generate_request_based_on_case(key_down) return [bytes(req, 'utf-8') for req in request.split('|')] def _generate_request_based_on_case(self, key_down: int | None = None) -> str: """ - Generate a request based on the current state of the object. + Generate a formatted request string based on various conditions and cases. - The request is determined by a set of conditions defined in the `request_mapper` dictionary. - Each condition is associated with a method that generates the request for that condition. + This method evaluates different scenarios using the `request_mapper` dictionary, + which maps integer case keys to specific conditions and methods. + If the condition for a given case is met, the corresponding method is called to generate the request. + If no conditions match, the raw request is returned appended with a newline. - :param key_down: One (1) indicates when G-Key was pushed down and zero (0) when G-Key is up - :return: A string representing the generated request based on the given conditions and parameters. + :param key_down: Integer representing a key state, it can be either a specific value such as `KEY_UP` or + `None` for cases where key down state is not applicable. + :return: Returns a string representing the generated request based on the active case conditions. """ + class CaseDict(TypedDict): condition: bool method: partial + request_mapper: dict[int, CaseDict] = { 1: {'condition': self.is_push_button and isinstance(self.key, Gkey), 'method': partial(self.__generate_push_btn_req_for_gkey_and_mouse, key_down)}, @@ -1235,30 +1377,218 @@ class CaseDict(TypedDict): def __generate_push_btn_req_for_gkey_and_mouse(self, key_down: int | None) -> str: """ - Generate a push button request for GKey and MouseButton. + Generate a string request for handling a push-button action for both keyboard keys and mouse events. + + The function constructs a command string based on the control name and whether a key is being pressed. - :param key_down: Optional integer representing the key pressed down. + :param key_down: Either an integer value representing the key being pressed or None if no key action is specified. + :return: A string formatted as a control name concatenated with the key_down value followed by a newline character. """ return f'{self.ctrl_name} {key_down}\n' def __generate_push_btn_req_for_lcd_button(self) -> str: - """Generate the push button request for the LCD button.""" + """ + Generate a push button request sequence for an LCD button. + + This method constructs and returns the string that represents the sequence of key press + events (key down followed by key up) for the LCD button associated with the `ctrl_name`. + The request is formatted as a string where each line corresponds to an event. + + :raises ValueError: If `ctrl_name` is not properly set or invalid. + :return: A string representing the key press sequence for the LCD button. + """ return f'{self.ctrl_name} {KEY_DOWN}\n|{self.ctrl_name} {KEY_UP}\n' @staticmethod def __generate_empty() -> str: - """Generate an empty string.""" + """ + Generate and return an empty string. + + It does not take any arguments and simply returns an empty string. + + :return: An empty string. + """ return '' def __generate_cycle_request(self) -> str: - """Generate a cycle request.""" + """ + Generate a cycle request string. + + This method constructs and returns a string representing a button's cycle request. + It typically combines the control name with the next value intended for the button and appends a newline character at the end. + + :return: A formatted string representing the cycle request, including the control name and the next value for the button. + """ return f'{self.ctrl_name} {self._get_next_value_for_button()}\n' def __generate_custom_request(self) -> str: - """Generate a custom request from the raw request.""" + """ + Generate and formats a custom request string based on the raw request input. + + This method processes the raw request string to extract and properly format its content, + specifically for custom request types. + It splits the raw request using the defined delimiter for custom request types and + re-formats the request content using newline characters. + + :raises AttributeError: If `self.raw_request` is not properly formatted or the expected split pattern is missing. + :raises IndexError: If the split raw request string does not contain the expected elements after processing. + :return: A formatted request string with replaced delimiters. + """ request = self.raw_request.split(f'{RequestType.CUSTOM.value} ')[1] request = request.replace('|', '\n|') return request.strip('|') def __str__(self) -> str: return f'{self.ctrl_name}: {self.raw_request}' + + +class Color(Enum): + """A superset of HTML 4.0 color names used in CSS 1.""" + aliceblue = 0xf0f8ff + antiquewhite = 0xfaebd7 + aqua = 0x00ffff + aquamarine = 0x7fffd4 + azure = 0xf0ffff + beige = 0xf5f5dc + bisque = 0xffe4c4 + black = 0x000000 + blanchedalmond = 0xffebcd + blue = 0x0000ff + blueviolet = 0x8a2be2 + brown = 0xa52a2a + burlywood = 0xdeb887 + cadetblue = 0x5f9ea0 + chartreuse = 0x7fff00 + chocolate = 0xd2691e + coral = 0xff7f50 + cornflowerblue = 0x6495ed + cornsilk = 0xfff8dc + crimson = 0xdc143c + cyan = 0x00ffff + darkblue = 0x00008b + darkcyan = 0x008b8b + darkgoldenrod = 0xb8860b + darkgray = 0xa9a9a9 + darkgrey = 0xa9a9a9 + darkgreen = 0x006400 + darkkhaki = 0xbdb76b + darkmagenta = 0x8b008b + darkolivegreen = 0x556b2f + darkorange = 0xff8c00 + darkorchid = 0x9932cc + darkred = 0x8b0000 + darksalmon = 0xe9967a + darkseagreen = 0x8fbc8f + darkslateblue = 0x483d8b + darkslategray = 0x2f4f4f + darkslategrey = 0x2f4f4f + darkturquoise = 0x00ced1 + darkviolet = 0x9400d3 + deeppink = 0xff1493 + deepskyblue = 0x00bfff + dimgray = 0x696969 + dimgrey = 0x696969 + dodgerblue = 0x1e90ff + firebrick = 0xb22222 + floralwhite = 0xfffaf0 + forestgreen = 0x228b22 + fuchsia = 0xff00ff + gainsboro = 0xdcdcdc + ghostwhite = 0xf8f8ff + gold = 0xffd700 + goldenrod = 0xdaa520 + gray = 0x808080 + grey = 0x808080 + green = 0x008000 + greenyellow = 0xadff2f + honeydew = 0xf0fff0 + hotpink = 0xff69b4 + indianred = 0xcd5c5c + indigo = 0x4b0082 + ivory = 0xfffff0 + khaki = 0xf0e68c + lavender = 0xe6e6fa + lavenderblush = 0xfff0f5 + lawngreen = 0x7cfc00 + lemonchiffon = 0xfffacd + lightblue = 0xadd8e6 + lightcoral = 0xf08080 + lightcyan = 0xe0ffff + lightgoldenrodyellow = 0xfafad2 + lightgreen = 0x90ee90 + lightgray = 0xd3d3d3 + lightgrey = 0xd3d3d3 + lightpink = 0xffb6c1 + lightsalmon = 0xffa07a + lightseagreen = 0x20b2aa + lightskyblue = 0x87cefa + lightslategray = 0x778899 + lightslategrey = 0x778899 + lightsteelblue = 0xb0c4de + lightyellow = 0xffffe0 + lime = 0x00ff00 + limegreen = 0x32cd32 + linen = 0xfaf0e6 + magenta = 0xff00ff + maroon = 0x800000 + mediumaquamarine = 0x66cdaa + mediumblue = 0x0000cd + mediumorchid = 0xba55d3 + mediumpurple = 0x9370db + mediumseagreen = 0x3cb371 + mediumslateblue = 0x7b68ee + mediumspringgreen = 0x00fa9a + mediumturquoise = 0x48d1cc + mediumvioletred = 0xc71585 + midnightblue = 0x191970 + mintcream = 0xf5fffa + mistyrose = 0xffe4e1 + moccasin = 0xffe4b5 + navajowhite = 0xffdead + navy = 0x000080 + oldlace = 0xfdf5e6 + olive = 0x808000 + olivedrab = 0x6b8e23 + orange = 0xffa500 + orangered = 0xff4500 + orchid = 0xda70d6 + palegoldenrod = 0xeee8aa + palegreen = 0x98fb98 + paleturquoise = 0xafeeee + palevioletred = 0xdb7093 + papayawhip = 0xffefd5 + peachpuff = 0xffdab9 + peru = 0xcd853f + pink = 0xffc0cb + plum = 0xdda0dd + powderblue = 0xb0e0e6 + purple = 0x800080 + rebeccapurple = 0x663399 + red = 0xff0000 + rosybrown = 0xbc8f8f + royalblue = 0x4169e1 + saddlebrown = 0x8b4513 + salmon = 0xfa8072 + sandybrown = 0xf4a460 + seagreen = 0x2e8b57 + seashell = 0xfff5ee + sienna = 0xa0522d + silver = 0xc0c0c0 + skyblue = 0x87ceeb + slateblue = 0x6a5acd + slategray = 0x708090 + slategrey = 0x708090 + snow = 0xfffafa + springgreen = 0x00ff7f + steelblue = 0x4682b4 + tan = 0xd2b48c + teal = 0x008080 + thistle = 0xd8bfd8 + tomato = 0xff6347 + turquoise = 0x40e0d0 + violet = 0xee82ee + wheat = 0xf5deb3 + white = 0xffffff + whitesmoke = 0xf5f5f5 + yellow = 0xffff00 + yellowgreen = 0x9acd32 diff --git a/src/dcspy/qt_gui.py b/src/dcspy/qt_gui.py index 7ed7761d3..472f487d0 100644 --- a/src/dcspy/qt_gui.py +++ b/src/dcspy/qt_gui.py @@ -1474,7 +1474,7 @@ def fetch_system_data(self, silence: bool = False) -> SystemData: :return: SystemData named tuple with all data """ system, _, release, ver, _, proc = uname() - dcs_type, dcs_ver = check_dcs_ver(Path(self.config['dcs'])) + dcs_ver = check_dcs_ver(Path(self.config['dcs'])) dcspy_ver = get_version_string(repo=DCSPY_REPO_NAME, current_ver=__version__, check=self.config['check_ver']) bios_ver = str(self._check_local_bios()) dcs_bios_ver = self._get_bios_full_version(silence=silence) @@ -1482,7 +1482,7 @@ def fetch_system_data(self, silence: bool = False) -> SystemData: if self.git_exec: from git import cmd git_ver = '.'.join([str(i) for i in cmd.Git().version_info]) - return SystemData(system=system, release=release, ver=ver, proc=proc, dcs_type=dcs_type, dcs_ver=dcs_ver, + return SystemData(system=system, release=release, ver=ver, proc=proc, dcs_ver=dcs_ver, dcspy_ver=dcspy_ver, bios_ver=bios_ver, dcs_bios_ver=dcs_bios_ver, git_ver=git_ver) def _run_file_dialog(self, last_dir: Callable[..., str], widget_name: str | None = None) -> str: @@ -1761,7 +1761,7 @@ def showEvent(self, event: QShowEvent): text += f'SHA: {d.dcs_bios_ver}' else: text += f'SHA: {d.dcs_bios_ver}' - text += f'
DCS World: {d.dcs_ver} ({d.dcs_type})' + text += f'
DCS World: {d.dcs_ver}' text += '

' self.l_info.setText(text) diff --git a/src/dcspy/resources/config.yaml b/src/dcspy/resources/config.yaml index 804f39f8e..07845d8df 100644 --- a/src/dcspy/resources/config.yaml +++ b/src/dcspy/resources/config.yaml @@ -4,8 +4,8 @@ check_bios: true check_ver: true completer_items: 20 current_plane: A-10C -dcs: C:/Program Files/Eagle Dynamics/DCS World OpenBeta -dcsbios: C:/Users/UNKNOWN/Saved Games/DCS.openbeta/Scripts/DCS-BIOS +dcs: C:/Program Files/Eagle Dynamics/DCS World +dcsbios: C:/Users/UNKNOWN/Saved Games/DCS/Scripts/DCS-BIOS f16_ded_font: true font_color_l: 32 font_color_m: 22 diff --git a/src/dcspy/sdk/lcd_sdk.py b/src/dcspy/sdk/lcd_sdk.py index 3767b4433..396bb514b 100644 --- a/src/dcspy/sdk/lcd_sdk.py +++ b/src/dcspy/sdk/lcd_sdk.py @@ -4,8 +4,9 @@ from cffi import FFI, CDefError from PIL import Image -from dcspy.models import LcdButton, LcdSize, LcdType +from dcspy.models import Color, LcdButton, LcdSize, LcdType from dcspy.sdk import LcdDll, load_dll +from dcspy.utils import rgb LOG = getLogger(__name__) @@ -156,7 +157,7 @@ def logi_lcd_color_set_text(self, line_no: int, text: str, rgb: tuple[int, int, except AttributeError: return False - def update_text(self, txt: list[str]) -> None: + def update_text(self, txt: list[tuple[str, Color]]) -> None: """ Update display LCD with a list of text. @@ -164,13 +165,17 @@ def update_text(self, txt: list[str]) -> None: For color, LCD takes eight (8) elements of the list and displays as eight (8) rows. :param txt: List of strings to display, row by row """ + title = txt.pop(0) + title_txt = title[0] + title_color = rgb(title[1]) if self.logi_lcd_is_connected(LcdType.MONO): - for line_no, line in enumerate(txt): - self.logi_lcd_mono_set_text(line_no, line) + for line_no, txt_and_color in enumerate(txt[:4]): + self.logi_lcd_mono_set_text(line_no, txt_and_color[0]) self.logi_lcd_update() elif self.logi_lcd_is_connected(LcdType.COLOR): - for line_no, line in enumerate(txt): - self.logi_lcd_color_set_text(line_no, line) + self.logi_lcd_color_set_title(title_txt, title_color) + for line_no, txt_and_color in enumerate(txt): + self.logi_lcd_color_set_text(line_no, txt_and_color[0], rgb(txt_and_color[1])) self.logi_lcd_update() else: LOG.warning('LCD is not connected') diff --git a/src/dcspy/starter.py b/src/dcspy/starter.py index ae75f5b1a..7f5e90696 100644 --- a/src/dcspy/starter.py +++ b/src/dcspy/starter.py @@ -9,7 +9,7 @@ from dcspy import get_config_yaml_item from dcspy.dcsbios import ProtocolParser from dcspy.logitech import LogitechDevice -from dcspy.models import DCSPY_REPO_NAME, MULTICAST_IP, RECV_ADDR, LogitechDeviceModel +from dcspy.models import DCSPY_REPO_NAME, MULTICAST_IP, RECV_ADDR, Color, LogitechDeviceModel from dcspy.utils import check_bios_ver, get_version_string LOG = getLogger(__name__) @@ -30,7 +30,7 @@ def _handle_connection(logi_device: LogitechDevice, parser: ProtocolParser, sock """ start_time = time() LOG.info('Waiting for DCS connection...') - support_banner = _supporters(text=f'Huge thanks to: {", ".join(SUPPORTERS)} and others! For support and help! ', width=26) + support_banner = _supporters(text=f'Huge thanks to: {", ".join(SUPPORTERS)} and others! For support and help! ', width=37) while not event.is_set(): try: dcs_bios_resp = sock.recv(2048) @@ -84,10 +84,11 @@ def _sock_err_handler(logi_device: LogitechDevice, start_time: float, ver_string LOG.debug(f'Main loop socket error: {exp}') LOOP_FLAG = False wait_time = gmtime(time() - start_time) - logi_device.display = ['Logitech LCD OK', - f'No data from DCS: {wait_time.tm_min:02d}:{wait_time.tm_sec:02d}', - f'{next(support_iter)}', - ver_string] + logi_device.text = [(' DCSpy ', Color.orange), + ('Logitech LCD OK', Color.lightgreen), + (f'No data from DCS: {wait_time.tm_min:02d}:{wait_time.tm_sec:02d}', Color.red), + (f'{next(support_iter)}', Color.yellow), + (ver_string, Color.white)] def _prepare_socket() -> socket.socket: @@ -120,4 +121,8 @@ def dcspy_run(model: LogitechDeviceModel, event: Event) -> None: dcspy_ver = get_version_string(repo=DCSPY_REPO_NAME, current_ver=__version__, check=bool(get_config_yaml_item('check_ver'))) _handle_connection(logi_device=logi_dev, parser=parser, sock=dcs_sock, ver_string=dcspy_ver, event=event) LOG.info('DCSpy stopped.') - logi_dev.display = ['DCSpy stopped', '', f'DCSpy: {dcspy_ver}', f'DCS-BIOS: {check_bios_ver(bios_path=get_config_yaml_item("dcsbios"))}'] + logi_dev.text = [(' DCSpy ', Color.orange), + ('DCSpy stopped', Color.red), + ('', Color.black), + (f'DCSpy: {dcspy_ver}', Color.white), + (f'DCS-BIOS: {check_bios_ver(bios_path=get_config_yaml_item("dcsbios"))}', Color.white)] diff --git a/src/dcspy/utils.py b/src/dcspy/utils.py index 12b569a2d..eee527314 100644 --- a/src/dcspy/utils.py +++ b/src/dcspy/utils.py @@ -21,11 +21,12 @@ import yaml from packaging import version +from PIL import ImageColor from psutil import process_iter from requests import get -from dcspy.models import (CTRL_LIST_SEPARATOR, AnyButton, BiosValue, ControlDepiction, ControlKeyData, DcsBiosPlaneData, DcspyConfigYaml, Release, RequestModel, - get_key_instance) +from dcspy.models import (CTRL_LIST_SEPARATOR, AnyButton, BiosValue, Color, ControlDepiction, ControlKeyData, DcsBiosPlaneData, DcspyConfigYaml, LcdMode, + Release, RequestModel, get_key_instance) try: import git @@ -39,7 +40,7 @@ with open(DEFAULT_YAML_FILE) as c_file: defaults_cfg: DcspyConfigYaml = yaml.load(c_file, Loader=yaml.SafeLoader) - defaults_cfg['dcsbios'] = f'C:\\Users\\{environ.get("USERNAME", "UNKNOWN")}\\Saved Games\\DCS.openbeta\\Scripts\\DCS-BIOS' + defaults_cfg['dcsbios'] = f'C:\\Users\\{environ.get("USERNAME", "UNKNOWN")}\\Saved Games\\DCS\\Scripts\\DCS-BIOS' def get_default_yaml(local_appdata: bool = False) -> Path: @@ -115,7 +116,7 @@ def get_version_string(repo: str, current_ver: str | version.Version, check: boo Generate formatted string with version number. :param repo: Format '/'. - :param current_ver: string or Version object. + :param current_ver: String or Version object. :param check: Version online. :return: Formatted version as string. """ @@ -170,26 +171,23 @@ def proc_is_running(name: str) -> int: return 0 -def check_dcs_ver(dcs_path: Path) -> tuple[str, str]: +def check_dcs_ver(dcs_path: Path) -> str: """ Check DCS version and release type. :param dcs_path: Path to DCS installation directory - :return: DCS type and version as strings + :return: DCS version as strings """ - result_type, result_ver = 'Unknown', 'Unknown' + result_ver = 'Unknown' try: with open(file=dcs_path / 'autoupdate.cfg', encoding='utf-8') as autoupdate_cfg: autoupdate_data = autoupdate_cfg.read() except (FileNotFoundError, PermissionError) as err: LOG.debug(f'{type(err).__name__}: {err.filename}') else: - result_type = 'stable' - if dcs_type := search(r'"branch":\s"([\w.]*)"', autoupdate_data): - result_type = str(dcs_type.group(1)) if dcs_ver := search(r'"version":\s"([\d.]*)"', autoupdate_data): result_ver = str(dcs_ver.group(1)) - return result_type, result_ver + return result_ver def check_bios_ver(bios_path: Path | str) -> version.Version: @@ -327,9 +325,9 @@ def count_files(directory: Path, extension: str) -> int: """ Count files with extension in directory. - :param directory: as Path object - :param extension: file extension - :return: number of files + :param directory: As Path object + :param extension: File extension + :return: Number of files """ try: json_files = [f.name for f in directory.iterdir() if f.is_file() and f.suffix == f'.{extension}'] @@ -779,7 +777,7 @@ def generate_bios_jsons_with_lupa(dcs_save_games: Path, local_compile='./Scripts Using the Lupa library, first it will tries use LuaJIT 2.1 if not it will fall back to Lua 5.1 - :param dcs_save_games: Full path to Saved Games\DCS.openbeta directory. + :param dcs_save_games: Full path to Saved Games\DCS directory. :param local_compile: Relative path to LocalCompile.lua file. """ try: @@ -802,3 +800,39 @@ def generate_bios_jsons_with_lupa(dcs_save_games: Path, local_compile='./Scripts finally: chdir(previous_dir) LOG.debug(f"Change directory back to: {getcwd()}") + + +def rgba(c: Color, /, mode: LcdMode | int = LcdMode.TRUE_COLOR) -> tuple[int, ...] | int: + """ + Convert a color to a single integer or tuple of integers. + + This depends on given mode/alpha channel: + * If mode is an integer, then return a tuple of RGBA channels. + * If mode is a LcdMode.TRUE_COLOR, then return a tuple of RGBA channels. + * If mode is a LcdMode.BLACK_WHITE, then return a single integer. + + :param c: Color name to convert + :param mode: Mode of the LCD or alpha channel as integer + :return: tuple with RGBA channels or single integer + """ + if isinstance(mode, int): + return *rgb(c), mode + else: + return ImageColor.getcolor(color=c.name, mode=mode.value) + + +def rgb(c: Color, /) -> tuple[int, int, int]: + """ + Convert a Color instance to its RGB components as a tuple of integers. + + The function extracts the red, green, and blue components from the + color's value, which is expected to be a single integer representing + a 24-bit RGB color. + + :param c: An instance of Color, whose value is a 24-bit RGB integer. + :return: A tuple containing the red, green, and blue components. + """ + red = (c.value >> 16) & 0xff + green = (c.value >> 8) & 0xff + blue = c.value & 0xff + return red, green, blue diff --git a/tests/conftest.py b/tests/conftest.py index 8370ab94c..aa584cb22 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -119,7 +119,7 @@ def test_dcs_bios(resources): :return: Path to DCS-BIOS """ - return resources / 'DCS.openbeta' / 'Scripts' / 'DCS-BIOS' + return resources / 'DCS' / 'Scripts' / 'DCS-BIOS' @fixture() @@ -265,8 +265,8 @@ def default_config(): """Get default configuration dict.""" from os import environ return { - 'dcsbios': f'C:\\Users\\{environ.get("USERNAME", "UNKNOWN")}\\Saved Games\\DCS.openbeta\\Scripts\\DCS-BIOS', - 'dcs': 'C:\\Program Files\\Eagle Dynamics\\DCS World OpenBeta', 'device': 'G13', 'save_lcd': False, 'show_gui': True, 'autostart': False, + 'dcsbios': f'C:\\Users\\{environ.get("USERNAME", "UNKNOWN")}\\Saved Games\\DCS\\Scripts\\DCS-BIOS', + 'dcs': 'C:\\Program Files\\Eagle Dynamics\\DCS World', 'device': 'G13', 'save_lcd': False, 'show_gui': True, 'autostart': False, 'verbose': False, 'check_bios': True, 'check_ver': True, 'font_name': models.DEFAULT_FONT_NAME, 'font_mono_m': 11, 'font_mono_s': 9, 'font_mono_l': 16, 'font_color_m': 22, 'font_color_s': 18, 'font_color_l': 32, 'f16_ded_font': True, 'git_bios': True, 'git_bios_ref': 'master', 'toolbar_style': 0, 'toolbar_area': 4, 'gkeys_area': 2, 'gkeys_float': False, 'theme_mode': 'system', 'theme_color': 'dark-blue', 'completer_items': 20, @@ -312,19 +312,17 @@ def autoupdate1_cfg(): """Mock for correct autoupdate_cfg.""" return """{ "WARNING": "DO NOT EDIT this file. You may break your install!", - "branch": "openbeta", - "version": "2.7.16.28157", - "timestamp": "20220729-154039", + "version": "2.9.10.4160", + "timestamp": "20241210-221435", "arch": "x86_64", "lang": "EN", "modules": [ "WORLD", - "FA-18C", - "NS430_MI-8MTV2", - "NS430", - "MI-8MTV2", - "UH-1H", - "A-10C", + "CAUCASUS_terrain", + ], + "launch": "bin/DCS.exe" +} + """ @@ -348,26 +346,6 @@ def autoupdate2_cfg(): """ -@fixture() -def autoupdate3_cfg(): - """Mock for wrong autoupdate_cfg.""" - return """{ - "WARNING": "DO NOT EDIT this file. You may break your install!", - "version": "2.7.18.28157", - "timestamp": "20220729-154039", - "arch": "x86_64", - "lang": "EN", - "modules": [ - "WORLD", - "FA-18C", - "NS430_MI-8MTV2", - "NS430", - "MI-8MTV2", - "UH-1H", - "A-10C", -""" - - # <=><=><=><=><=> airplane bios data <=><=><=><=><=> @fixture() def apache_pre_mode_bios_data(): diff --git a/tests/resources/DCS.openbeta/Logs/dcs.log b/tests/resources/DCS/Logs/dcs.log similarity index 100% rename from tests/resources/DCS.openbeta/Logs/dcs.log rename to tests/resources/DCS/Logs/dcs.log diff --git a/tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/A-10C.json b/tests/resources/DCS/Scripts/DCS-BIOS/doc/json/A-10C.json similarity index 100% rename from tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/A-10C.json rename to tests/resources/DCS/Scripts/DCS-BIOS/doc/json/A-10C.json diff --git a/tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/AH-64D.json b/tests/resources/DCS/Scripts/DCS-BIOS/doc/json/AH-64D.json similarity index 100% rename from tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/AH-64D.json rename to tests/resources/DCS/Scripts/DCS-BIOS/doc/json/AH-64D.json diff --git a/tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/AV8BNA.json b/tests/resources/DCS/Scripts/DCS-BIOS/doc/json/AV8BNA.json similarity index 100% rename from tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/AV8BNA.json rename to tests/resources/DCS/Scripts/DCS-BIOS/doc/json/AV8BNA.json diff --git a/tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/AircraftAliases.json b/tests/resources/DCS/Scripts/DCS-BIOS/doc/json/AircraftAliases.json similarity index 100% rename from tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/AircraftAliases.json rename to tests/resources/DCS/Scripts/DCS-BIOS/doc/json/AircraftAliases.json diff --git a/tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/CommonData.json b/tests/resources/DCS/Scripts/DCS-BIOS/doc/json/CommonData.json similarity index 100% rename from tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/CommonData.json rename to tests/resources/DCS/Scripts/DCS-BIOS/doc/json/CommonData.json diff --git a/tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/F-14.json b/tests/resources/DCS/Scripts/DCS-BIOS/doc/json/F-14.json similarity index 100% rename from tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/F-14.json rename to tests/resources/DCS/Scripts/DCS-BIOS/doc/json/F-14.json diff --git a/tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/F-15E.json b/tests/resources/DCS/Scripts/DCS-BIOS/doc/json/F-15E.json similarity index 100% rename from tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/F-15E.json rename to tests/resources/DCS/Scripts/DCS-BIOS/doc/json/F-15E.json diff --git a/tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/F-16C_50.json b/tests/resources/DCS/Scripts/DCS-BIOS/doc/json/F-16C_50.json similarity index 100% rename from tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/F-16C_50.json rename to tests/resources/DCS/Scripts/DCS-BIOS/doc/json/F-16C_50.json diff --git a/tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/F-4E.json b/tests/resources/DCS/Scripts/DCS-BIOS/doc/json/F-4E.json similarity index 100% rename from tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/F-4E.json rename to tests/resources/DCS/Scripts/DCS-BIOS/doc/json/F-4E.json diff --git a/tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/FA-18C_hornet.json b/tests/resources/DCS/Scripts/DCS-BIOS/doc/json/FA-18C_hornet.json similarity index 100% rename from tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/FA-18C_hornet.json rename to tests/resources/DCS/Scripts/DCS-BIOS/doc/json/FA-18C_hornet.json diff --git a/tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/Ka-50.json b/tests/resources/DCS/Scripts/DCS-BIOS/doc/json/Ka-50.json similarity index 100% rename from tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/Ka-50.json rename to tests/resources/DCS/Scripts/DCS-BIOS/doc/json/Ka-50.json diff --git a/tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/Mi-24P.json b/tests/resources/DCS/Scripts/DCS-BIOS/doc/json/Mi-24P.json similarity index 100% rename from tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/Mi-24P.json rename to tests/resources/DCS/Scripts/DCS-BIOS/doc/json/Mi-24P.json diff --git a/tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/Mi-8MT.json b/tests/resources/DCS/Scripts/DCS-BIOS/doc/json/Mi-8MT.json similarity index 100% rename from tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/Mi-8MT.json rename to tests/resources/DCS/Scripts/DCS-BIOS/doc/json/Mi-8MT.json diff --git a/tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/NS430.json b/tests/resources/DCS/Scripts/DCS-BIOS/doc/json/NS430.json similarity index 100% rename from tests/resources/DCS.openbeta/Scripts/DCS-BIOS/doc/json/NS430.json rename to tests/resources/DCS/Scripts/DCS-BIOS/doc/json/NS430.json diff --git a/tests/resources/DCS.openbeta/Scripts/DCS-BIOS/lib/modules/common_modules/CommonData.lua b/tests/resources/DCS/Scripts/DCS-BIOS/lib/modules/common_modules/CommonData.lua similarity index 100% rename from tests/resources/DCS.openbeta/Scripts/DCS-BIOS/lib/modules/common_modules/CommonData.lua rename to tests/resources/DCS/Scripts/DCS-BIOS/lib/modules/common_modules/CommonData.lua diff --git a/tests/test_lcd_sdk.py b/tests/test_lcd_sdk.py index 240a0f20d..30e739924 100644 --- a/tests/test_lcd_sdk.py +++ b/tests/test_lcd_sdk.py @@ -2,7 +2,8 @@ from pytest import mark -from dcspy.models import LcdButton, LcdSize, LcdType +from dcspy.models import Color, LcdButton, LcdSize, LcdType +from dcspy.utils import rgb @mark.parametrize('function, lcd, args, result', [ @@ -55,8 +56,8 @@ def test_update_display(c_func, effect, lcd, size): @mark.parametrize('c_func, effect, lcd, list_txt', [ - ('logi_lcd_mono_set_text', [True], LcdType.MONO, ['1', '2', '3', '4']), - ('logi_lcd_color_set_text', [False, True], LcdType.COLOR, ['1', '2', '3', '4', '5', '6', '7', '8']) + ('logi_lcd_mono_set_text', [True], LcdType.MONO, [('0', Color.white), ('1', Color.white), ('2', Color.white), ('3', Color.white), ('4', Color.white)]), + ('logi_lcd_color_set_text', [False, True], LcdType.COLOR, [('0', Color.white), ('1', Color.white), ('2', Color.white), ('3', Color.white), ('4', Color.white), ('5', Color.white), ('6', Color.white), ('7', Color.white), ('8', Color.white)]) ], ids=['Mono', 'Color']) def test_update_text(c_func, effect, lcd, list_txt): from dcspy.sdk.lcd_sdk import LcdSdkManager @@ -68,7 +69,10 @@ def test_update_text(c_func, effect, lcd, list_txt): with patch.object(lcd_sdk, 'logi_lcd_update', return_value=True): lcd_sdk.update_text(list_txt) connected.assert_called_with(lcd) - set_text.assert_has_calls([call(i, j) for i, j in enumerate(list_txt)]) + if lcd == LcdType.MONO: + set_text.assert_has_calls([call(i, j[0]) for i, j in enumerate(list_txt)]) + elif lcd == LcdType.COLOR: + set_text.assert_has_calls([call(i, j[0], rgb(j[1])) for i, j in enumerate(list_txt)]) @mark.parametrize('c_funcs, effect, lcd, clear, text', [ @@ -100,7 +104,7 @@ def test_update_text_no_lcd(): lcd_sdk = LcdSdkManager('test', LcdType.MONO) with patch.object(lcd_sdk, 'logi_lcd_is_connected', side_effect=[False, False]) as connected: - lcd_sdk.update_text(['1']) + lcd_sdk.update_text([('0', Color.red), ('1', Color.green)]) connected.assert_has_calls([call(LcdType.MONO), call(LcdType.COLOR)]) diff --git a/tests/test_logitech.py b/tests/test_logitech.py index 00c5e52b3..173fba8c7 100644 --- a/tests/test_logitech.py +++ b/tests/test_logitech.py @@ -2,7 +2,7 @@ from pytest import mark -from dcspy.models import Gkey, LcdButton, LcdInfo, LcdMode, LcdSize, LcdType, MouseButton +from dcspy.models import Color, Gkey, LcdButton, LcdInfo, LcdMode, LcdSize, LcdType, MouseButton def test_keyboard_base_basic_check(keyboard_base): @@ -10,7 +10,7 @@ def test_keyboard_base_basic_check(keyboard_base): assert str(keyboard_base) == 'LogitechDevice: 160x43' logitech_repr = repr(keyboard_base) - data = ('bios_name', 'plane_name', 'plane_detected', 'lcdbutton_pressed', 'cfg', 'socket', '_display', + data = ('bios_name', 'plane_name', 'plane_detected', 'lcdbutton_pressed', 'cfg', 'socket', '_text', 'parser', 'ProtocolParser', 'plane', 'BasicAircraft', 'model', 'LogitechDeviceModel', 'LcdInfo', 'LcdMode', 'FreeTypeFont', @@ -84,7 +84,7 @@ def test_keyboard_mono_gkey_callback_handler(key_idx, mode, key_down, mouse, cal @mark.benchmark -@mark.parametrize('plane_str, bios_name, plane, display, detect', [ +@mark.parametrize('plane_str, bios_name, plane, text, detect', [ ('FA-18C_hornet', '', 'FA18Chornet', ['Detected aircraft:', 'F/A-18C Hornet'], True), ('F-16C_50', '', 'F16C50', ['Detected aircraft:', 'F-16C Viper'], True), ('F-4E-45MC', '', 'F4E45MC', ['Detected aircraft:', 'F-4E Phantom II'], True), @@ -124,12 +124,12 @@ def test_keyboard_mono_gkey_callback_handler(key_idx, mode, key_down, mouse, cal 'A-10A', 'F-117 Nighthawk', 'Empty']) -def test_keyboard_mono_detecting_plane(plane_str, bios_name, plane, display, detect, keyboard_mono): +def test_keyboard_mono_detecting_plane(plane_str, bios_name, plane, text, detect, keyboard_mono): with patch('dcspy.logitech.get_planes_list', return_value=['SpitfireLFMkIX', 'F-22A']): keyboard_mono.detecting_plane(plane_str) assert keyboard_mono.plane_name == plane assert keyboard_mono.bios_name == bios_name - assert keyboard_mono._display == display + assert keyboard_mono.messages == text assert keyboard_mono.plane_detected is detect @@ -150,9 +150,10 @@ def test_check_keyboard_display_and_prepare_image(mode, width, height, lcd_type, assert isinstance(keyboard.plane, BasicAircraft) assert isinstance(keyboard.model.lcd_info, LcdInfo) assert keyboard.model.lcd_info.type == lcd_type - assert isinstance(keyboard.display, list) - keyboard.display = ['1', '2'] - assert len(keyboard.display) == 2 + assert isinstance(keyboard.text, list) + keyboard.text = [('1', Color.red), ('2', Color.green)] + assert len(keyboard.text) == 2 + keyboard.display() upd_display.assert_called_once() img = keyboard._prepare_image() @@ -168,9 +169,10 @@ def test_check_keyboard_text(keyboard, protocol_parser, sock, request): from dcspy.sdk.lcd_sdk import LcdSdkManager keyboard = request.getfixturevalue(keyboard) + txt_list = [('0', Color.white), ('1', Color.green), ('2', Color.white), ('3', Color.green), ('4', Color.white), ('5', Color.green)] with patch.object(LcdSdkManager, 'update_text') as upd_txt: - keyboard.text(['1', '2']) - upd_txt.assert_called_once() + keyboard.text = txt_list + upd_txt.assert_called_once_with(txt_list) @mark.benchmark diff --git a/tests/test_migration.py b/tests/test_migration.py index ea7e29656..ec5e50857 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -27,8 +27,8 @@ def test_generate_config(): 'check_ver': True, 'completer_items': 20, 'current_plane': 'A-10C', - 'dcs': 'C:/Program Files/Eagle Dynamics/DCS World OpenBeta', - 'dcsbios': f'C:\\Users\\{environ.get("USERNAME", "UNKNOWN")}\\Saved Games\\DCS.openbeta\\Scripts\\DCS-BIOS', + 'dcs': 'C:/Program Files/Eagle Dynamics/DCS World', + 'dcsbios': f'C:\\Users\\{environ.get("USERNAME", "UNKNOWN")}\\Saved Games\\DCS\\Scripts\\DCS-BIOS', 'f16_ded_font': True, 'font_color_l': 32, 'font_color_m': 22, diff --git a/tests/test_models.py b/tests/test_models.py index 147faa3bc..b26ed66e0 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -356,7 +356,7 @@ def test_get_inputs_for_plane(test_dcs_bios): def test_get_sha_of_system_data(): from dcspy.models import SystemData - sys_data = SystemData(system='Windows', release='10', ver='10.0.19045', proc='Intel64 Family 6 Model 158 Stepping 9, GenuineIntel', dcs_type='openbeta', + sys_data = SystemData(system='Windows', release='10', ver='10.0.19045', proc='Intel64 Family 6 Model 158 Stepping 9, GenuineIntel', dcs_ver='2.9.0.47168', dcspy_ver='v2.9.9', bios_ver='0.8.3', dcs_bios_ver='07771667 from: 26-Oct-2023 06:59:50', git_ver='2.41.0') assert sys_data.sha == '07771667' diff --git a/tests/test_starter.py b/tests/test_starter.py index af728f551..d88b532cc 100644 --- a/tests/test_starter.py +++ b/tests/test_starter.py @@ -19,7 +19,7 @@ def test_sock_err_handler(keyboard_mono): start_time = time() starter._sock_err_handler(logi_device=keyboard_mono, start_time=start_time, ver_string=ver_string, support_iter=(i for i in '12'), exp=Exception()) - assert keyboard_mono.display == ['Logitech LCD OK', 'No data from DCS: 00:00', '1', ver_string] + assert keyboard_mono.messages == ['Logitech LCD OK', 'No data from DCS: 00:00', '1', ver_string] def test_supporters(): diff --git a/tests/test_utils.py b/tests/test_utils.py index 72bf4c6d7..c76c281ee 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -7,7 +7,7 @@ from pytest import mark, raises from dcspy import utils -from dcspy.models import DEFAULT_FONT_NAME, get_key_instance +from dcspy.models import DEFAULT_FONT_NAME, Color, LcdMode, get_key_instance def test_check_ver_can_not_check(): @@ -100,8 +100,8 @@ def test_dummy_save_load_migrate(tmpdir): 'autostart': False, 'completer_items': 20, 'current_plane': 'A-10C', - 'dcsbios': f'C:\\Users\\{environ.get("USERNAME", "UNKNOWN")}\\Saved Games\\DCS.openbeta\\Scripts\\DCS-BIOS', - 'dcs': 'C:/Program Files/Eagle Dynamics/DCS World OpenBeta', + 'dcsbios': f'C:\\Users\\{environ.get("USERNAME", "UNKNOWN")}\\Saved Games\\DCS\\Scripts\\DCS-BIOS', + 'dcs': 'C:/Program Files/Eagle Dynamics/DCS World', 'verbose': False, 'check_bios': True, 'check_ver': True, @@ -129,26 +129,20 @@ def test_dummy_save_load_migrate(tmpdir): def test_check_dcs_ver_file_exists_with_ver(autoupdate1_cfg): with patch('dcspy.utils.open', mock_open(read_data=autoupdate1_cfg)): dcs_ver = utils.check_dcs_ver(Path('')) - assert dcs_ver == ('openbeta', '2.7.16.28157') + assert dcs_ver == '2.9.10.4160' def test_check_dcs_ver_file_exists_without_ver(autoupdate2_cfg): with patch('dcspy.utils.open', mock_open(read_data=autoupdate2_cfg)): dcs_ver = utils.check_dcs_ver(Path('')) - assert dcs_ver == ('openbeta', 'Unknown') - - -def test_check_dcs_ver_file_exists_without_branch(autoupdate3_cfg): - with patch('dcspy.utils.open', mock_open(read_data=autoupdate3_cfg)): - dcs_ver = utils.check_dcs_ver(Path('')) - assert dcs_ver == ('stable', '2.7.18.28157') + assert dcs_ver == 'Unknown' @mark.parametrize('side_effect', [FileNotFoundError, PermissionError]) def test_check_dcs_ver_file_not_exists(side_effect): with patch('dcspy.utils.open', side_effect=side_effect): dcs_ver = utils.check_dcs_ver(Path('')) - assert dcs_ver == ('Unknown', 'Unknown') + assert dcs_ver == 'Unknown' def test_check_bios_ver_new_location(tmpdir): @@ -474,3 +468,13 @@ def test_generate_bios_jsons_with_lupa(test_saved_games): mosquito = utils.get_full_bios_for_plane(plane='MosquitoFBMkVI', bios_dir=test_saved_games / 'Scripts' / 'DCS-BIOS') assert len(mosquito.root) == 27 assert sum(len(values) for values in mosquito.root.values()) == 299 + + +@mark.parametrize('color, mode, result', [ + (Color.azure, LcdMode.TRUE_COLOR, (240, 255, 255, 255)), + (Color.beige, LcdMode.BLACK_WHITE, 242), + (Color.honeydew, 0, (240, 255, 240, 0)), + (Color.khaki, 204, (240, 230, 140, 204)), +]) +def test_color(color, mode, result): + assert utils.rgba(color, mode=mode) == result diff --git a/uml/classes.puml b/uml/classes.puml index e239e13d1..e505a626a 100644 --- a/uml/classes.puml +++ b/uml/classes.puml @@ -47,7 +47,7 @@ package logitech { + lcdbutton_pressed = False : bool + model: LogitechDeviceModel + __init__(ProtocolParser, socket, FontsConfig) - + display(message : List[str]) -> List[str] + + display() + detecting_plane() + load_new_plane(str) + check_buttons() -> LcdButton @@ -56,7 +56,8 @@ package logitech { + lcd_sdk: LcdSdkManager + gkey_callback_handler(int, int, int, int) + clear() - + text(List[str]) + + text(message: List[Tuple[str, Color]]) -> List[Tuple[str, Color]] + + messages() -> List[str] # _prepare_image() -> Image # _send_request(button: Union[LcdButton, Gkey, MouseButton], Optional[int]) } @@ -267,6 +268,12 @@ package models { + total() -> int } + class Color <<(E,yellow)>> { + + beige = 0xf5f5dc + + bisque = 0xffe4c4 + + black = 0x000000 + } + BiosValueInt *-- IntBuffArgs BiosValueStr *-- StrBuffArgs @@ -301,7 +308,7 @@ package sdk{ + logi_lcd_color_set_background(List[Tuple[int, int, int, int]]) + logi_lcd_color_set_title(str, Tuple[int, int, int]) + logi_lcd_color_set_text(int, str, Tuple[int, int, int]) - + update_text(List[str]) + + update_text(List[Tuple[str, Color]]) + update_display(Image) + clear_display(bool) # _clear_mono(bool)