• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

emcek / dcspy / 17220256879

25 Aug 2025 08:42PM UTC coverage: 97.686% (+0.001%) from 97.685%
17220256879

push

github

emcek
generate f16 color not ded screenshot

374 of 380 branches covered (98.42%)

Branch coverage included in aggregate %.

4608 of 4720 relevant lines covered (97.63%)

0.98 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.52
/src/dcspy/models.py
1
from __future__ import annotations
1✔
2

3
from collections.abc import Callable, Iterator, Mapping, Sequence
1✔
4
from ctypes import c_void_p
1✔
5
from datetime import datetime
1✔
6
from enum import Enum, IntEnum
1✔
7
from functools import partial
1✔
8
from os import environ
1✔
9
from pathlib import Path
1✔
10
from platform import architecture
1✔
11
from re import search
1✔
12
from sys import maxsize
1✔
13
from typing import Any, Final, TypedDict, TypeVar, Union
1✔
14

15
from _ctypes import sizeof
1✔
16
from packaging import version
1✔
17
from PIL import Image, ImageFont
1✔
18
from pydantic import BaseModel, ConfigDict, RootModel, field_validator
1✔
19

20
__version__ = '3.7.0'
1✔
21

22
# Network
23
SEND_ADDR: Final = ('127.0.0.1', 7778)
1✔
24
UDP_PORT: Final = 5010
1✔
25
RECV_ADDR: Final = ('', UDP_PORT)
1✔
26
MULTICAST_IP: Final = '239.255.50.10'
1✔
27

28
# G Key
29
LOGITECH_MAX_GKEYS: Final = 30
1✔
30
LOGITECH_MAX_M_STATES: Final = 4
1✔
31

32
# Key press
33
KEY_DOWN: Final = 1
1✔
34
KEY_UP: Final = 0
1✔
35

36
# Others
37
NO_OF_LCD_SCREENSHOTS: Final = 301
1✔
38
TIME_BETWEEN_REQUESTS: Final = 0.2
1✔
39
LOCAL_APPDATA: Final = True
1✔
40
DCSPY_REPO_NAME: Final = 'emcek/dcspy'
1✔
41
BIOS_REPO_NAME: Final = 'DCS-Skunkworks/dcs-bios'
1✔
42
DEFAULT_FONT_NAME: Final = 'consola.ttf'
1✔
43
CTRL_LIST_SEPARATOR: Final = '--'
1✔
44
CONFIG_YAML: Final = 'config.yaml'
1✔
45
DEFAULT_YAML_FILE: Final = Path(__file__).parent / 'resources' / CONFIG_YAML
1✔
46
SUPPORTED_CRAFTS = {
1✔
47
    'FA18Chornet': {'name': 'F/A-18C Hornet', 'bios': 'FA-18C_hornet'},
48
    'Ka50': {'name': 'Ka-50 Black Shark II', 'bios': 'Ka-50'},
49
    'Ka503': {'name': 'Ka-50 Black Shark III', 'bios': 'Ka-50_3'},
50
    'Mi8MT': {'name': 'Mi-8MTV2 Magnificent Eight', 'bios': 'Mi-8MT'},
51
    'Mi24P': {'name': 'Mi-24P Hind', 'bios': 'Mi-24P'},
52
    'F16C50': {'name': 'F-16C Viper', 'bios': 'F-16C_50'},
53
    'F15ESE': {'name': 'F-15ESE Eagle', 'bios': 'F-15ESE'},
54
    'AH64DBLKII': {'name': 'AH-64D Apache', 'bios': 'AH-64D_BLK_II'},
55
    'A10C': {'name': 'A-10C Warthog', 'bios': 'A-10C'},
56
    'A10C2': {'name': 'A-10C II Tank Killer', 'bios': 'A-10C_2'},
57
    'F14A135GR': {'name': 'F-14A Tomcat', 'bios': 'F-14A-135-GR'},
58
    'F14B': {'name': 'F-14B Tomcat', 'bios': 'F-14B'},
59
    'AV8BNA': {'name': 'AV-8B N/A Harrier', 'bios': 'AV8BNA'},
60
    'F4E45MC': {'name': 'F-4E Phantom II', 'bios': 'F-4E-45MC'},
61
}
62

63
BiosValue = Union[str, int, float]
1✔
64

65

66
class AircraftKwargs(TypedDict):
1✔
67
    """Represent the keyword arguments expected by the Aircraft class."""
68
    update_display: Callable[[Image.Image], None]
1✔
69
    bios_data: Mapping[str, BiosValue]
1✔
70

71

72
class Input(BaseModel):
1✔
73
    """Input base class of inputs section of Control."""
74
    description: str
1✔
75

76
    def get(self, attribute: str, default=None) -> Any | None:
1✔
77
        """
78
        Access an attribute and get default when is not available.
79

80
        :param attribute:
81
        :param default:
82
        :return:
83
        """
84
        return self.model_dump().get(attribute, default)
1✔
85

86

87
class FixedStep(Input):
1✔
88
    """FixedStep input interface of inputs a section of Control."""
89
    interface: str = 'fixed_step'
1✔
90

91
    @field_validator('interface')
1✔
92
    def validate_interface(cls, value: str) -> str:
1✔
93
        """
94
        Validate.
95

96
        :param value:
97
        :return:
98
        """
99
        if value != 'fixed_step':
1✔
100
            raise ValueError("Invalid value for 'interface'. Only 'fixed_step' is allowed.")
1✔
101
        return value
1✔
102

103

104
class VariableStep(Input):
1✔
105
    """VariableStep input interface of the inputs section of Control."""
106
    interface: str = 'variable_step'
1✔
107
    max_value: int
1✔
108
    suggested_step: int
1✔
109

110
    @field_validator('interface')
1✔
111
    def validate_interface(cls, value: str) -> str:
1✔
112
        """
113
        Validate.
114

115
        :param value:
116
        :return:
117
        """
118
        if value != 'variable_step':
1✔
119
            raise ValueError("Invalid value for 'interface'. Only 'variable_step' is allowed.")
1✔
120
        return value
1✔
121

122

123
class SetState(Input):
1✔
124
    """SetState input interface of the inputs section of Control."""
125
    interface: str = 'set_state'
1✔
126
    max_value: int
1✔
127

128
    @field_validator('interface')
1✔
129
    def validate_interface(cls, value: str) -> str:
1✔
130
        """
131
        Validate.
132

133
        :param value:
134
        :return:
135
        """
136
        if value != 'set_state':
1✔
137
            raise ValueError("Invalid value for 'interface'. Only 'set_state' is allowed.")
1✔
138
        return value
1✔
139

140

141
class Action(Input):
1✔
142
    """Action input interface of the inputs section of Control."""
143
    argument: str
1✔
144
    interface: str = 'action'
1✔
145

146
    @field_validator('interface')
1✔
147
    def validate_interface(cls, value: str) -> str:
1✔
148
        """
149
        Validate.
150

151
        :param value:
152
        :return:
153
        """
154
        if value != 'action':
1✔
155
            raise ValueError("Invalid value for 'interface'. Only 'action' is allowed.")
1✔
156
        return value
1✔
157

158

159
class SetString(Input):
1✔
160
    """SetString input interface of inputs a section of Control."""
161
    interface: str = 'set_string'
1✔
162

163
    @field_validator('interface')
1✔
164
    def validate_interface(cls, value: str) -> str:
1✔
165
        """
166
        Validate.
167

168
        :param value:
169
        :return:
170
        """
171
        if value != 'set_string':
1✔
172
            raise ValueError("Invalid value for 'interface'. Only 'set_string' is allowed.")
1✔
173
        return value
1✔
174

175

176
Inputs = Union[FixedStep, VariableStep, SetState, Action, SetString]
1✔
177

178

179
class Output(BaseModel):
1✔
180
    """Output base class of outputs section of Control."""
181
    address: int
1✔
182
    description: str
1✔
183
    suffix: str
1✔
184

185

186
class OutputStr(Output):
1✔
187
    """String output interface of outputs a section of Control."""
188
    max_length: int
1✔
189
    type: str
1✔
190

191
    @field_validator('type')
1✔
192
    def validate_interface(cls, value: str) -> str:
1✔
193
        """
194
        Validate.
195

196
        :param value:
197
        :return:
198
        """
199
        if value != 'string':
1✔
200
            raise ValueError("Invalid value for 'interface'. Only 'string' is allowed.")
1✔
201
        return value
1✔
202

203

204
class OutputInt(Output):
1✔
205
    """Integer output interface of the outputs section of Control."""
206
    mask: int
1✔
207
    max_value: int
1✔
208
    shift_by: int
1✔
209
    type: str
1✔
210

211
    @field_validator('type')
1✔
212
    def validate_interface(cls, value: str) -> str:
1✔
213
        """
214
        Validate.
215

216
        :param value:
217
        :return:
218
        """
219
        if value != 'integer':
1✔
220
            raise ValueError("Invalid value for 'interface'. Only 'integer' is allowed.")
1✔
221
        return value
1✔
222

223

224
# ---------------- DCS-BIOS ----------------
225
class IntBuffArgs(BaseModel):
1✔
226
    """Arguments of BIOS Integer Buffer."""
227
    address: int
1✔
228
    mask: int
1✔
229
    shift_by: int
1✔
230

231

232
class BiosValueInt(BaseModel):
1✔
233
    """Value of BIOS Integer Buffer."""
234
    klass: str
1✔
235
    args: IntBuffArgs
1✔
236
    value: int
1✔
237
    max_value: int
1✔
238

239

240
class StrBuffArgs(BaseModel):
1✔
241
    """Arguments of BIOS String Buffer."""
242
    address: int
1✔
243
    max_length: int
1✔
244

245

246
class BiosValueStr(BaseModel):
1✔
247
    """Value of BIOS String Buffer."""
248
    klass: str
1✔
249
    args: StrBuffArgs
1✔
250
    value: str
1✔
251

252

253
class ControlDepiction(BaseModel):
1✔
254
    """Represent the depiction of a control."""
255
    name: str
1✔
256
    description: str
1✔
257

258

259
class ControlKeyData:
1✔
260
    """Describes input data for cockpit controller."""
261

262
    def __init__(self, name: str, description: str, max_value: int, suggested_step: int = 1) -> None:
1✔
263
        """
264
        Define a type of input for a cockpit controller.
265

266
        :param name: Name of the input
267
        :param description: Short description
268
        :param max_value: Max value (zero-based)
269
        :param suggested_step: One (1) by default
270
        """
271
        self.name = name
1✔
272
        self.description = description
1✔
273
        self.max_value = max_value
1✔
274
        self.suggested_step = suggested_step
1✔
275
        self.list_dict: list[Inputs] = []
1✔
276

277
    def __repr__(self) -> str:
1✔
278
        return f'KeyControl({self.name}: {self.description} - max_value={self.max_value}, suggested_step={self.suggested_step}'
1✔
279

280
    def __bool__(self) -> bool:
1✔
281
        """Return True if both `max_value` and `suggested_step`: are truthy, False otherwise."""
282
        if not all([self.max_value, self.suggested_step]):
1✔
283
            return False
1✔
284
        return True
1✔
285

286
    @classmethod
1✔
287
    def from_control(cls, /, ctrl: Control) -> ControlKeyData:
1✔
288
        """
289
        Construct an object based on Control BIOS Model.
290

291
        :param ctrl: Control BIOS model
292
        :return: ControlKeyData instance
293
        """
294
        try:
1✔
295
            max_value = cls._get_max_value(ctrl.inputs)
1✔
296
            suggested_step: int = max(d.get('suggested_step', 1) for d in ctrl.inputs)  # type: ignore[type-var, assignment]
1✔
297
        except ValueError:
1✔
298
            max_value = 0
1✔
299
            suggested_step = 0
1✔
300
        instance = cls(name=ctrl.identifier, description=ctrl.description, max_value=max_value, suggested_step=suggested_step)
1✔
301
        instance.list_dict = ctrl.inputs
1✔
302
        return instance
1✔
303

304
    @staticmethod
1✔
305
    def _get_max_value(list_of_dicts: list[Inputs]) -> int:
1✔
306
        """
307
        Get a maximum value from a list of dictionaries.
308

309
        :param list_of_dicts: List of inputs
310
        :return: Maximum value of all inputs
311
        """
312
        max_value, real_zero = ControlKeyData.__get_max(list_of_dicts)
1✔
313
        if all([not real_zero, not max_value]):
1✔
314
            max_value = 1
1✔
315
        return max_value
1✔
316

317
    @staticmethod
1✔
318
    def __get_max(list_of_dicts: list[Inputs]) -> tuple[int, bool]:
1✔
319
        """
320
        Maximum value found in the 'max_value' attribute of the objects in the list.
321

322
        Check if any of the objects had a 'max_value' of 0.
323

324
        :param list_of_dicts: List of dictionaries containing objects of types FixedStep, VariableStep, SetState, Action, SetString.
325
        :return: A tuple containing the maximum value and a boolean value indicating if any of the objects had a 'max_value' of 0.
326
        """
327
        __real_zero = False
1✔
328
        __max_values = []
1✔
329
        for d in list_of_dicts:
1✔
330
            try:
1✔
331
                __max_values.append(d.max_value)  # type: ignore[union-attr]
1✔
332
                if d.max_value == 0:  # type: ignore[union-attr]
1✔
333
                    __real_zero = True
1✔
334
                    break
1✔
335
            except AttributeError:
1✔
336
                __max_values.append(0)
1✔
337
        return max(__max_values), __real_zero
1✔
338

339
    @property
1✔
340
    def depiction(self) -> ControlDepiction:
1✔
341
        """
342
        Return the depiction of the control.
343

344
        :return: ControlDepiction object representing the control's name and description.
345
        """
346
        return ControlDepiction(name=self.name, description=self.description)
1✔
347

348
    @property
1✔
349
    def input_len(self) -> int:
1✔
350
        """
351
        Get a length of input dictionary.
352

353
        :return: Number of inputs as integer
354
        """
355
        return len(self.list_dict)
1✔
356

357
    @property
1✔
358
    def one_input(self) -> bool:
1✔
359
        """
360
        Check if an input has only one input dict.
361

362
        :return: True if ControlKeyData has only one input, False otherwise
363
        """
364
        return bool(len(self.list_dict) == 1)
1✔
365

366
    @property
1✔
367
    def has_fixed_step(self) -> bool:
1✔
368
        """
369
        Check if input has fixed step input.
370

371
        :return: True if ControlKeyData has fixed step input, False otherwise
372
        """
373
        return any(isinstance(d, FixedStep) for d in self.list_dict)
1✔
374

375
    @property
1✔
376
    def has_variable_step(self) -> bool:
1✔
377
        """
378
        Check if input has variable step input.
379

380
        :return: True if ControlKeyData has variable step input, False otherwise
381
        """
382
        return any(isinstance(d, VariableStep) for d in self.list_dict)
1✔
383

384
    @property
1✔
385
    def has_set_state(self) -> bool:
1✔
386
        """
387
        Check if input has set state input.
388

389
        :return: True if ControlKeyData has set state input, False otherwise
390
        """
391
        return any(isinstance(d, SetState) for d in self.list_dict)
1✔
392

393
    @property
1✔
394
    def has_action(self) -> bool:
1✔
395
        """
396
        Check if input has action input.
397

398
        :return: True if ControlKeyData has action input, False otherwise
399
        """
400
        return any(isinstance(d, Action) for d in self.list_dict)
1✔
401

402
    @property
1✔
403
    def has_set_string(self) -> bool:
1✔
404
        """
405
        Check if input has set string input.
406

407
        :return: True if ControlKeyData has set string input, False otherwise
408
        """
409
        return any(isinstance(d, SetString) for d in self.list_dict)
1✔
410

411
    @property
1✔
412
    def is_push_button(self) -> bool:
1✔
413
        """
414
        Check if the controller is a push button type.
415

416
        :return: True if a controller is a push button type, False otherwise
417
        """
418
        return self.has_fixed_step and self.has_set_state and self.max_value == 1
1✔
419

420

421
class Control(BaseModel):
1✔
422
    """Control section of the BIOS model."""
423
    api_variant: str | None = None
1✔
424
    category: str
1✔
425
    control_type: str
1✔
426
    description: str
1✔
427
    identifier: str
1✔
428
    inputs: list[FixedStep | VariableStep | SetState | Action | SetString]
1✔
429
    outputs: list[OutputStr | OutputInt]
1✔
430

431
    @property
1✔
432
    def input(self) -> ControlKeyData:
1✔
433
        """
434
        Extract inputs data.
435

436
        :return: ControlKeyData
437
        """
438
        return ControlKeyData.from_control(ctrl=self)
1✔
439

440
    @property
1✔
441
    def output(self) -> BiosValueInt | BiosValueStr:
1✔
442
        """
443
        Extract outputs data.
444

445
        :return: Union[BiosValueInt, BiosValueStr]
446
        """
447
        if isinstance(self.outputs[0], OutputInt):
1✔
448
            return BiosValueInt(klass='IntegerBuffer',
1✔
449
                                args=IntBuffArgs(address=self.outputs[0].address, mask=self.outputs[0].mask, shift_by=self.outputs[0].shift_by),
450
                                value=int(),
451
                                max_value=self.outputs[0].max_value)
452
        else:
453
            return BiosValueStr(klass='StringBuffer',
1✔
454
                                args=StrBuffArgs(address=self.outputs[0].address, max_length=self.outputs[0].max_length),
455
                                value='')
456

457
    @classmethod
1✔
458
    def make_empty(cls) -> Control:
1✔
459
        """
460
        Make an empty Control object with default values assigned to its attributes.
461

462
        :return: Control an object with empty values.
463
        """
464
        return cls(api_variant='', category='', control_type='', description='', identifier='', inputs=[], outputs=[])
1✔
465

466
    def __bool__(self) -> bool:
1✔
467
        """Return True if all attributes: are truthy, False otherwise."""
468
        return all([self.api_variant, self.category, self.control_type, self.description, self.identifier, len(self.inputs), len(self.outputs)])
1✔
469

470

471
class DcsBiosPlaneData(RootModel):
1✔
472
    """DcsBios plane data model."""
473
    root: dict[str, dict[str, Control]]
1✔
474

475
    def get_ctrl(self, ctrl_name: str) -> Control:
1✔
476
        """
477
        Get Control from DCS-BIOS with name.
478

479
        :param ctrl_name: Control name
480
        :return: Control instance
481
        """
482
        for controllers in self.root.values():
1✔
483
            for ctrl, data in controllers.items():
1✔
484
                if ctrl == ctrl_name:
1✔
485
                    return Control.model_validate(data)
1✔
486
        return Control.make_empty()
1✔
487

488
    def get_inputs(self) -> dict[str, dict[str, ControlKeyData]]:
1✔
489
        """
490
        Get dict with all not empty inputs for plane.
491

492
        Inputs are grouped in original sections.
493

494
        :return: Dict with sections and ControlKeyData models.
495
        """
496
        ctrl_key: dict[str, dict[str, ControlKeyData]] = {}
1✔
497

498
        for section, controllers in self.root.items():
1✔
499
            ctrl_key[section] = {}
1✔
500
            for ctrl, data in controllers.items():
1✔
501
                ctrl_input = Control.model_validate(data).input
1✔
502
                if ctrl_input and not ctrl_input.has_set_string:
1✔
503
                    ctrl_key[section][ctrl] = ctrl_input
1✔
504
            if not ctrl_key[section]:
1✔
505
                del ctrl_key[section]
1✔
506
        return ctrl_key
1✔
507

508

509
class CycleButton(BaseModel):
1✔
510
    """Map BIOS key string with iterator to keep a current value."""
511
    model_config = ConfigDict(arbitrary_types_allowed=True)
1✔
512

513
    ctrl_name: str
1✔
514
    step: int = 1
1✔
515
    max_value: int = 1
1✔
516
    iter: Iterator[int] = iter([0])
1✔
517

518
    @classmethod
1✔
519
    def from_request(cls, /, req: str) -> CycleButton:
1✔
520
        """
521
        Convert a request string to a `CycleButton` instance by extracting the necessary details from the request's components.
522

523
        The request is expected to follow a predefined structure where its components
524
        are separated by spaces.
525

526
        :param req: A string a request expected to contain `control_name`, an underscore, `step`, and `max_value`, separated by spaces.
527
        :return: Instance of `CycleButton` based on extracted data.
528
        """
529
        selector, _, step, max_value = req.split(' ')
1✔
530
        return CycleButton(ctrl_name=selector, step=int(step), max_value=int(max_value))
1✔
531

532
    def __bool__(self) -> bool:
1✔
533
        """Return True if any of the attributes: `step`, `max_value`, `ctrl_name` is truthy, False otherwise."""
534
        return not all([not self.step, not self.max_value, not self.ctrl_name])
1✔
535

536

537
class GuiPlaneInputRequest(BaseModel):
1✔
538
    """
539
    Represents a GUI plane input request.
540

541
    This class is used to construct and manage input requests originating from
542
    a graphical interface, such as radio buttons or other control widgets,
543
    that interact with plane systems.
544
    It allows for structured generation of requests based on provided parameters or
545
    configurations and provides utility methods to convert data into request objects.
546
    """
547
    identifier: str
1✔
548
    request: str
1✔
549
    widget_iface: str
1✔
550

551
    @classmethod
1✔
552
    def from_control_key(cls, ctrl_key: ControlKeyData, rb_iface: str, custom_value: str = '') -> GuiPlaneInputRequest:
1✔
553
        """
554
        Create an instance of GuiPlaneInputRequest based on provided control key data, a request type and optional custom value.
555

556
        The method generates a request string for the GUI widget interface determined by the specified request type (rb_iface)
557
        using information from the ControlKeyData object (ctrl_key).
558
        If a custom value is provided, it incorporates the value into the generated request for certain request types.
559

560
        :param ctrl_key: A ControlKeyData object used to specify the control key's attributes, such as its name, suggested step, and maximum value.
561
        :param rb_iface: A string that represents the requested widget interface type, options include types such as 'rb_action', 'rb_fixed_step_inc', etc.
562
        :param custom_value: An optional string used to provide a custom value for specific request types ('rb_custom' or 'rb_set_state').
563
        :return: A GuiPlaneInputRequest object initialized with the identifier, generated request string, and the specified widget interface type.
564
        """
565
        rb_iface_request = {
1✔
566
            'rb_action': f'{ctrl_key.name} TOGGLE',
567
            'rb_fixed_step_inc': f'{ctrl_key.name} INC',
568
            'rb_fixed_step_dec': f'{ctrl_key.name} DEC',
569
            'rb_cycle': f'{ctrl_key.name} CYCLE {ctrl_key.suggested_step} {ctrl_key.max_value}',
570
            'rb_custom': f'{ctrl_key.name} {RequestType.CUSTOM.value} {custom_value}',
571
            'rb_push_button': f'{ctrl_key.name} {RequestType.PUSH_BUTTON.value}',
572
            'rb_variable_step_plus': f'{ctrl_key.name} +{ctrl_key.suggested_step}',
573
            'rb_variable_step_minus': f'{ctrl_key.name} -{ctrl_key.suggested_step}',
574
            'rb_set_state': f'{ctrl_key.name} {custom_value}',
575
        }
576
        return cls(identifier=ctrl_key.name, request=rb_iface_request[rb_iface], widget_iface=rb_iface)
1✔
577

578
    @classmethod
1✔
579
    def from_plane_gkeys(cls, /, plane_gkeys: dict[str, str]) -> dict[str, GuiPlaneInputRequest]:
1✔
580
        """
581
        Create a dictionary mapping unique plane keys to `GuiPlaneInputRequest` objects, based on input configuration data.
582

583
        The method processes each key-value pair where the value contains a request type and determines the appropriate widget
584
        interface based on specified keywords.
585
        A mapping dictionary is used to identify widget interfaces corresponding to request types.
586

587
        :param plane_gkeys: A dictionary where each key is a plane identifier (string) and the value is
588
                            a space-separated string of configuration data that includes a request type.
589
        :return: A dictionary mapping each plane identifier (string) to a `GuiPlaneInputRequest` instance.
590
        """
591
        input_reqs = {}
1✔
592
        req_keyword_rb_iface = {
1✔
593
            RequestType.CUSTOM.value: 'rb_custom',
594
            RequestType.PUSH_BUTTON.value: 'rb_push_button',
595
            'TOGGLE': 'rb_action',
596
            'INC': 'rb_fixed_step_inc',
597
            'DEC': 'rb_fixed_step_dec',
598
            'CYCLE': 'rb_cycle',
599
            '+': 'rb_variable_step_plus',
600
            '-': 'rb_variable_step_minus',
601
            ' ': 'rb_set_state',
602
        }
603

604
        for gkey, data in plane_gkeys.items():
1✔
605
            try:
1✔
606
                iface = next(rb_iface for req_suffix, rb_iface in req_keyword_rb_iface.items() if req_suffix in data)
1✔
607
            except StopIteration:
1✔
608
                data = ''
1✔
609
                iface = ''
1✔
610
            input_reqs[gkey] = GuiPlaneInputRequest(identifier=data.split(' ')[0], request=data, widget_iface=iface)
1✔
611
        return input_reqs
1✔
612

613
    @classmethod
1✔
614
    def make_empty(cls) -> GuiPlaneInputRequest:
1✔
615
        """
616
        Create an empty GuiPlaneInputRequest object with default values assigned to its attributes.
617

618
        :return: An instance of GuiPlaneInputRequest with default empty values
619
        """
620
        return cls(identifier='', request='', widget_iface='')
1✔
621

622

623
class LedConstants(Enum):
1✔
624
    """LED constants."""
625
    LOGI_LED_DURATION_INFINITE = 0
1✔
626
    LOGI_DEVICETYPE_MONOCHROME = 1
1✔
627
    LOGI_DEVICETYPE_RGB = 2
1✔
628
    LOGI_DEVICETYPE_ALL = 3  # LOGI_DEVICETYPE_MONOCHROME | LOGI_DEVICETYPE_RGB
1✔
629

630

631
class LcdButton(Enum):
1✔
632
    """LCD Buttons."""
633
    NONE = 0x0
1✔
634
    ONE = 0x1
1✔
635
    TWO = 0x2
1✔
636
    THREE = 0x4
1✔
637
    FOUR = 0x8
1✔
638
    LEFT = 0x100
1✔
639
    RIGHT = 0x200
1✔
640
    OK = 0x400
1✔
641
    CANCEL = 0x800
1✔
642
    UP = 0x1000
1✔
643
    DOWN = 0x2000
1✔
644
    MENU = 0x4000
1✔
645

646
    def __str__(self) -> str:
1✔
647
        return self.name
1✔
648

649

650
class MouseButton(BaseModel):
1✔
651
    """
652
    Representation of a mouse button.
653

654
    Provides functionality for working with mouse buttons, including conversion
655
    to string, boolean evaluation, hashing, and constructing instances from YAML
656
    strings.
657
    Supports generating sequences of mouse buttons within a specified range.
658
    """
659
    button: int = 0
1✔
660

661
    def __str__(self) -> str:
1✔
662
        return f'M_{self.button}'
1✔
663

664
    def __bool__(self) -> bool:
1✔
665
        """Return False when button value is zero."""
666
        return bool(self.button)
1✔
667

668
    def __hash__(self) -> int:
1✔
669
        """Hash will be the same for any two MouseButton instances with the same button value."""
670
        return hash(self.button)
1✔
671

672
    @classmethod
1✔
673
    def from_yaml(cls, /, yaml_str: str) -> MouseButton:
1✔
674
        """
675
        Create a MouseButton object from a YAML string representation.
676

677
        This method parses a given YAML string to extract the button number
678
        encoded in the format `M_<i>` (such as `M_1`, `M_2`, etc.) and generates
679
        a MouseButton instance for the corresponding button.
680
        If the format does not conform to expectations and parsing fails, a ValueError is raised.
681

682
        :param yaml_str: The YAML string representing the mouse button in the format `M_<i>`.
683
        :return: A MouseButton instance derived from the specified YAML string.
684
        :raises ValueError: If the provided YAML string does not match the expected format `M_<i>`.
685
        """
686
        match = search(r'M_(\d+)', yaml_str)
1✔
687
        if match:
1✔
688
            return cls(button=int(match.group(1)))
1✔
689
        raise ValueError(f'Invalid MouseButton format: {yaml_str}. Expected: M_<i>')
1✔
690

691
    @staticmethod
1✔
692
    def generate(button_range: tuple[int, int]) -> Sequence[MouseButton]:
1✔
693
        """
694
        Generate a sequence of MouseButton objects based on the provided range.
695

696
        This utility creates MouseButton instances for each integer value within
697
        the inclusive range defined by the ``button_range`` tuple.
698

699
        :param button_range: A tuple of two integers, representing the start and end of the range (inclusive) for generating MouseButton objects.
700
        :return: A tuple containing instantiated MouseButton objects for each value in the specified range.
701
        """
702
        return tuple(MouseButton(button=m) for m in range(button_range[0], button_range[1] + 1))
1✔
703

704

705
class LcdType(Enum):
1✔
706
    """LCD Type."""
707
    NONE = 0
1✔
708
    MONO = 1
1✔
709
    COLOR = 2
1✔
710

711

712
class LcdSize(Enum):
1✔
713
    """LCD dimensions."""
714
    NONE = 0
1✔
715
    MONO_WIDTH = 160
1✔
716
    MONO_HEIGHT = 43
1✔
717
    COLOR_WIDTH = 320
1✔
718
    COLOR_HEIGHT = 240
1✔
719

720

721
class LcdMode(Enum):
1✔
722
    """LCD Mode."""
723
    NONE = '0'
1✔
724
    BLACK_WHITE = '1'
1✔
725
    TRUE_COLOR = 'RGBA'
1✔
726

727

728
class FontsConfig(BaseModel):
1✔
729
    """Fonts configuration for LcdInfo."""
730
    name: str
1✔
731
    small: int
1✔
732
    medium: int
1✔
733
    large: int
1✔
734
    ded_font: bool = False
1✔
735

736

737
class LcdInfo(BaseModel):
1✔
738
    """LCD info."""
739
    model_config = ConfigDict(arbitrary_types_allowed=True)
1✔
740

741
    width: LcdSize
1✔
742
    height: LcdSize
1✔
743
    type: LcdType
1✔
744
    foreground: int | tuple[int, int, int, int]
1✔
745
    background: int | tuple[int, int, int, int]
1✔
746
    mode: LcdMode
1✔
747
    line_spacing: int
1✔
748
    font_xs: ImageFont.FreeTypeFont | None = None
1✔
749
    font_s: ImageFont.FreeTypeFont | None = None
1✔
750
    font_l: ImageFont.FreeTypeFont | None = None
1✔
751
    font_ded: ImageFont.FreeTypeFont | None = None
1✔
752

753
    def set_fonts(self, fonts: FontsConfig) -> None:
1✔
754
        """
755
        Set fonts configuration.
756

757
        :param fonts: fonts configuration
758
        """
759
        self.font_xs = ImageFont.truetype(fonts.name, fonts.small)
1✔
760
        self.font_s = ImageFont.truetype(fonts.name, fonts.medium)
1✔
761
        self.font_l = ImageFont.truetype(fonts.name, fonts.large)
1✔
762
        self.font_ded = None
1✔
763
        if fonts.ded_font:
1✔
764
            path_falcon_ded = Path(__file__) / '..' / 'resources' / 'falconded.ttf'
1✔
765
            self.font_ded = ImageFont.truetype(str(path_falcon_ded.resolve()), 25)
1✔
766

767
    def __str__(self) -> str:
1✔
768
        return f'{self.type.name.capitalize()} LCD: {self.width.value}x{self.height.value} px'
1✔
769

770

771
NoneLcd = LcdInfo(width=LcdSize.NONE, height=LcdSize.NONE, type=LcdType.NONE, line_spacing=0,
1✔
772
                  foreground=0, background=0, mode=LcdMode.NONE)
773
LcdMono = LcdInfo(width=LcdSize.MONO_WIDTH, height=LcdSize.MONO_HEIGHT, type=LcdType.MONO, line_spacing=10,
1✔
774
                  foreground=255, background=0, mode=LcdMode.BLACK_WHITE)
775
LcdColor = LcdInfo(width=LcdSize.COLOR_WIDTH, height=LcdSize.COLOR_HEIGHT, type=LcdType.COLOR, line_spacing=40,
1✔
776
                   foreground=(0, 255, 0, 255), background=(0, 0, 0, 0), mode=LcdMode.TRUE_COLOR)
777

778

779
class Gkey(BaseModel):
1✔
780
    """Logitech G-Key."""
781
    key: int
1✔
782
    mode: int
1✔
783

784
    def __str__(self) -> str:
1✔
785
        """Return with format G<i>/M<j>."""
786
        return f'G{self.key}_M{self.mode}'
1✔
787

788
    def __bool__(self) -> bool:
1✔
789
        """Return False when any of value is zero."""
790
        return all([self.key, self.mode])
1✔
791

792
    def __hash__(self) -> int:
1✔
793
        """Hash will be the same for any two Gkey instances with the same key and mode values."""
794
        return hash((self.key, self.mode))
1✔
795

796
    @classmethod
1✔
797
    def from_yaml(cls, /, yaml_str: str) -> Gkey:
1✔
798
        """
799
        Construct Gkey from YAML string.
800

801
        :param yaml_str: G-Key string, example: G2_M1
802
        :return: Gkey instance
803
        """
804
        match = search(r'G(\d+)_M(\d+)', yaml_str)
1✔
805
        if match:
1✔
806
            return cls(**{k: int(i) for k, i in zip(('key', 'mode'), match.groups())})
1✔
807
        raise ValueError(f'Invalid Gkey format: {yaml_str}. Expected: G<i>_M<j>')
1✔
808

809
    @staticmethod
1✔
810
    def generate(key: int, mode: int) -> Sequence[Gkey]:
1✔
811
        """
812
        Generate a sequence of G-Keys.
813

814
        :param key: Number of keys
815
        :param mode: Number of modes
816
        :return: sequence of Gkey instances
817
        """
818
        return tuple(Gkey(key=k, mode=m) for k in range(1, key + 1) for m in range(1, mode + 1))
1✔
819

820

821
AnyButton = Union[LcdButton, Gkey, MouseButton]
1✔
822

823

824
class DeviceRowsNumber(BaseModel):
1✔
825
    """Represent the number of rows for different types of devices."""
826
    g_key: int = 0
1✔
827
    lcd_key: int = 0
1✔
828
    mouse_key: int = 0
1✔
829

830
    @property
1✔
831
    def total(self) -> int:
1✔
832
        """
833
        Get the total number of rows.
834

835
        :return: The total count of rows as an integer.
836
        """
837
        return sum([self.g_key, self.lcd_key, self.mouse_key])
1✔
838

839

840
class LogitechDeviceModel(BaseModel):
1✔
841
    """
842
    Logitech Device model.
843

844
    It describes all capabilities of any Logitech device.
845
    """
846
    klass: str
1✔
847
    no_g_modes: int = 0
1✔
848
    no_g_keys: int = 0
1✔
849
    btn_m_range: tuple[int, int] = (0, 0)
1✔
850
    lcd_keys: Sequence[LcdButton] = ()
1✔
851
    lcd_info: LcdInfo = NoneLcd
1✔
852

853
    def get_key_at(self, row: int, col: int) -> AnyButton | None:
1✔
854
        """
855
        Get the keys at the specified row and column in the table layout.
856

857
        :param row: The row index (zero-based).
858
        :param col: The column index (zero-based).
859
        :return: The key at the specified row and column, if it exists, otherwise None.
860
        """
861
        try:
1✔
862
            g_keys = [[Gkey(key=r, mode=c) for c in range(1, self.no_g_modes + 1)] for r in range(1, self.no_g_keys + 1)]
1✔
863
            lcd_buttons = []
1✔
864
            mouse_buttons = []
1✔
865

866
            if self.lcd_keys:
1✔
867
                lcd_buttons = [[lcd_key] + [None] * (self.no_g_modes - 1) for lcd_key in self.lcd_keys]
1✔
868
            if len(self.mouse_keys) > 1:
1✔
869
                mouse_buttons = [[mouse_key] + [None] * (self.no_g_modes - 1) for mouse_key in self.mouse_keys]
1✔
870

871
            table_layout = g_keys + lcd_buttons + mouse_buttons
1✔
872
            return table_layout[row][col]
1✔
873
        except IndexError:
1✔
874
            return None
1✔
875

876
    @property
1✔
877
    def rows(self) -> DeviceRowsNumber:
1✔
878
        """
879
        Get the number of rows for each key category.
880

881
        :return: A DeviceRowsNumber with the number of rows for each category.
882
        """
883
        return DeviceRowsNumber(
1✔
884
            g_key=self.no_g_keys,
885
            lcd_key=len(self.lcd_keys),
886
            mouse_key=0 if len(self.mouse_keys) == 1 else len(self.mouse_keys)
887
        )
888

889
    @property
1✔
890
    def cols(self) -> int:
1✔
891
        """
892
        Get the number of columns required.
893

894
        :return: The number of columns required.
895
        """
896
        mouse_btn_exist = 1 if self.btn_m_range != (0, 0) else 0
1✔
897
        lcd_btn_exists = 1 if self.lcd_keys else 0
1✔
898
        return max([self.no_g_modes, mouse_btn_exist, lcd_btn_exists])
1✔
899

900
    def __str__(self) -> str:
1✔
901
        result = []
1✔
902
        if self.lcd_info.type.value:
1✔
903
            result.append(f'{self.lcd_info}')
1✔
904
        if self.lcd_keys:
1✔
905
            lcd_buttons = ', '.join([str(lcd_btn) for lcd_btn in self.lcd_keys])
1✔
906
            result.append(f'LCD Buttons: {lcd_buttons}')
1✔
907
        if self.no_g_modes and self.no_g_keys:
1✔
908
            result.append(f'G-Keys: {self.no_g_keys} in {self.no_g_modes} modes')
1✔
909
        if self.btn_m_range[0] and self.btn_m_range[1]:
1✔
910
            result.append(f'Mouse Buttons: {self.btn_m_range[0]} to {self.btn_m_range[1]}')
1✔
911
        return '\n'.join(result)
1✔
912

913
    @property
1✔
914
    def g_keys(self) -> Sequence[Gkey]:
1✔
915
        """
916
        Generate a sequence of G-Keys.
917

918
        :return: A sequence of G-Keys.
919
        """
920
        return Gkey.generate(key=self.no_g_keys, mode=self.no_g_modes)
1✔
921

922
    @property
1✔
923
    def mouse_keys(self) -> Sequence[MouseButton]:
1✔
924
        """
925
        Generate a sequence of MouseButtons.
926

927
        :return: A sequence of MouseButtons.
928
        """
929
        return MouseButton.generate(button_range=self.btn_m_range)
1✔
930

931
    @property
1✔
932
    def lcd_name(self) -> str:
1✔
933
        """
934
        Get the LCD name in lower case.
935

936
        :return: The name of the LCD as a lowercase string.
937
        """
938
        return self.lcd_info.type.name.lower()
1✔
939

940

941
G19 = LogitechDeviceModel(klass='G19', no_g_modes=3, no_g_keys=12, lcd_info=LcdColor,
1✔
942
                          lcd_keys=(LcdButton.LEFT, LcdButton.RIGHT, LcdButton.OK, LcdButton.CANCEL, LcdButton.UP, LcdButton.DOWN, LcdButton.MENU))
943
G13 = LogitechDeviceModel(klass='G13', no_g_modes=3, no_g_keys=29, lcd_info=LcdMono,
1✔
944
                          lcd_keys=(LcdButton.ONE, LcdButton.TWO, LcdButton.THREE, LcdButton.FOUR))
945
G15v1 = LogitechDeviceModel(klass='G15v1', no_g_modes=3, no_g_keys=18, lcd_info=LcdMono,
1✔
946
                            lcd_keys=(LcdButton.ONE, LcdButton.TWO, LcdButton.THREE, LcdButton.FOUR))
947
G15v2 = LogitechDeviceModel(klass='G15v2', no_g_modes=3, no_g_keys=6, lcd_info=LcdMono,
1✔
948
                            lcd_keys=(LcdButton.ONE, LcdButton.TWO, LcdButton.THREE, LcdButton.FOUR))
949
G510 = LogitechDeviceModel(klass='G510', no_g_modes=3, no_g_keys=18, lcd_info=LcdMono,
1✔
950
                           lcd_keys=(LcdButton.ONE, LcdButton.TWO, LcdButton.THREE, LcdButton.FOUR))
951
LCD_KEYBOARDS_DEV = [G19, G510, G15v1, G15v2, G13]
1✔
952

953
G910 = LogitechDeviceModel(klass='G910', no_g_modes=3, no_g_keys=9)
1✔
954
G710 = LogitechDeviceModel(klass='G710', no_g_modes=3, no_g_keys=6)
1✔
955
G110 = LogitechDeviceModel(klass='G110', no_g_modes=3, no_g_keys=12)
1✔
956
G103 = LogitechDeviceModel(klass='G103', no_g_modes=3, no_g_keys=6)
1✔
957
G105 = LogitechDeviceModel(klass='G105', no_g_modes=3, no_g_keys=6)
1✔
958
G11 = LogitechDeviceModel(klass='G11', no_g_modes=3, no_g_keys=18)
1✔
959
KEYBOARDS_DEV = [G910, G710, G110, G103, G105, G11]
1✔
960

961
G35 = LogitechDeviceModel(klass='G35', no_g_modes=1, no_g_keys=3)
1✔
962
G633 = LogitechDeviceModel(klass='G633', no_g_modes=1, no_g_keys=3)
1✔
963
G930 = LogitechDeviceModel(klass='G930', no_g_modes=1, no_g_keys=3)
1✔
964
G933 = LogitechDeviceModel(klass='G933', no_g_modes=1, no_g_keys=3)
1✔
965
HEADPHONES_DEV = [G35, G633, G930, G933]
1✔
966

967
G600 = LogitechDeviceModel(klass='G600', btn_m_range=(6, 20))
1✔
968
G300 = LogitechDeviceModel(klass='G300', btn_m_range=(6, 9))
1✔
969
G400 = LogitechDeviceModel(klass='G400', btn_m_range=(6, 8))
1✔
970
G700 = LogitechDeviceModel(klass='G700', btn_m_range=(1, 13))
1✔
971
G9 = LogitechDeviceModel(klass='G9', btn_m_range=(4, 8))
1✔
972
MX518 = LogitechDeviceModel(klass='MX518', btn_m_range=(6, 8))
1✔
973
G402 = LogitechDeviceModel(klass='G402', btn_m_range=(1, 5))
1✔
974
G502 = LogitechDeviceModel(klass='G502', btn_m_range=(4, 8))
1✔
975
G602 = LogitechDeviceModel(klass='G602', btn_m_range=(6, 10))
1✔
976
MOUSES_DEV = [G600, G300, G400, G700, G9, MX518, G402, G502, G602]
1✔
977

978
ALL_DEV = LCD_KEYBOARDS_DEV + KEYBOARDS_DEV + HEADPHONES_DEV + MOUSES_DEV
1✔
979

980

981
class MsgBoxTypes(Enum):
1✔
982
    """Message box types."""
983
    INFO = 'information'
1✔
984
    QUESTION = 'question'
1✔
985
    WARNING = 'warning'
1✔
986
    CRITICAL = 'critical'
1✔
987
    ABOUT = 'about'
1✔
988
    ABOUT_QT = 'aboutQt'
1✔
989

990

991
class SystemData(BaseModel):
1✔
992
    """Stores system related information."""
993
    system: str
1✔
994
    release: str
1✔
995
    ver: str
1✔
996
    proc: str
1✔
997
    dcs_ver: str
1✔
998
    dcspy_ver: str
1✔
999
    bios_ver: str
1✔
1000
    dcs_bios_ver: str
1✔
1001
    git_ver: str
1✔
1002

1003
    @property
1✔
1004
    def sha(self) -> str:
1✔
1005
        """
1006
        Provides a property to retrieve the SHA part of the DCS-BIOS repo.
1007

1008
        :return: The extracted SHA value from the `dcs_bios_ver` string.
1009
        """
1010
        return self.dcs_bios_ver.split(' ')[0]
1✔
1011

1012

1013
ConfigValue = TypeVar('ConfigValue', str, int, float, bool)
1✔
1014
DcspyConfigYaml = dict[str, ConfigValue]
1✔
1015

1016

1017
class Direction(IntEnum):
1✔
1018
    """Direction of iteration."""
1019
    FORWARD = 1
1✔
1020
    BACKWARD = -1
1✔
1021

1022

1023
class ZigZagIterator:
1✔
1024
    """
1025
    An iterator that moves within a range in an oscillating pattern.
1026

1027
    The iterator starts at a given current value, progresses or retreats based on the defined step size
1028
    and changes a direction upon reaching the boundaries of the range (`max_val` and 0).
1029
    This allows for oscillating behavior within the specified limits.
1030
    The class also provides access to its current direction of iteration.
1031
    """
1032
    def __init__(self, current: int, max_val: int, step: int = 1) -> None:
1✔
1033
        """
1034
        Represent a simple iterator with a defined range and step increment.
1035

1036
        The iterator maintains a current value, a maximum limit, and adjusts
1037
        its progression based on the specified step.
1038
        It also tracks the direction of iteration internally.
1039

1040
        :param current: The starting point of the iterator.
1041
        :param max_val: The upper limit of the iterator range.
1042
        :param step: The increment value for each iteration, defaults to 1.
1043
        """
1044
        self.current = current
1✔
1045
        self.step = step
1✔
1046
        self.max_val = max_val
1✔
1047
        self._direction = Direction.FORWARD
1✔
1048

1049
    def __iter__(self) -> ZigZagIterator:
1✔
1050
        return self
×
1051

1052
    def __str__(self) -> str:
1✔
1053
        return f'current: {self.current} step: {self.step} max value: {self.max_val}'
1✔
1054

1055
    def __next__(self) -> int:
1✔
1056
        if self.current >= self.max_val:
1✔
1057
            self._direction = Direction.BACKWARD
1✔
1058
        elif self.current <= 0:
1✔
1059
            self._direction = Direction.FORWARD
1✔
1060
        self.current += self.step * self._direction
1✔
1061
        if self._direction == Direction.FORWARD:
1✔
1062
            self.current = min(self.current, self.max_val)
1✔
1063
        else:
1064
            self.current = max(0, self.current)
1✔
1065
        return self.current
1✔
1066

1067
    @property
1✔
1068
    def direction(self) -> Direction:
1✔
1069
        """
1070
        Represent the direction of an iterator or entity within a defined context.
1071

1072
        This property retrieves the current direction of the iterator.
1073

1074
        :return: The current direction of the iterator.
1075
        """
1076
        return self._direction
1✔
1077

1078
    @direction.setter
1✔
1079
    def direction(self, value: Direction) -> None:
1✔
1080
        """
1081
        Set the direction of the current instance.
1082

1083
        :param value: The new direction to assign to the instance.
1084
        """
1085
        self._direction = value
1✔
1086

1087

1088
class Asset(BaseModel):
1✔
1089
    """
1090
    Representation of an asset with metadata information.
1091

1092
    This class is used to encapsulate details about an asset such as its
1093
    URL, name, label, content type, size, and download location.
1094
    It also provides functionality to validate the asset's properties against specific criteria.
1095
    """
1096
    url: str
1✔
1097
    name: str
1✔
1098
    label: str
1✔
1099
    content_type: str
1✔
1100
    size: int
1✔
1101
    browser_download_url: str
1✔
1102

1103
    def get_asset_with_name(self, extension: str = '', file_name: str = '') -> Asset | None:
1✔
1104
        """
1105
        Retrieve the asset if its name matches the specified file extension and contains the given file name.
1106

1107
        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.
1108

1109
        :param extension: The file extension to check for.
1110
        :param file_name: The specific file name to look for within the asset's name.
1111
        :return: The Asset instance if the name matches, otherwise None.
1112
        """
1113
        if self.name.endswith(extension) and file_name in self.name:
1✔
1114
            return self
1✔
1115
        return None
1✔
1116

1117

1118
class Release(BaseModel):
1✔
1119
    """
1120
    Representation of a software release.
1121

1122
    The Release class provides detailed information about a specific release of a software project,
1123
    including metadata such as URLs, tags, names, and dates.
1124
    It also includes functionality to determine whether a release is the latest and to
1125
    retrieve downloadable assets.
1126
    """
1127
    url: str
1✔
1128
    html_url: str
1✔
1129
    tag_name: str
1✔
1130
    name: str
1✔
1131
    draft: bool
1✔
1132
    prerelease: bool
1✔
1133
    created_at: str
1✔
1134
    published_at: str
1✔
1135
    assets: list[Asset]
1✔
1136
    body: str
1✔
1137

1138
    def is_latest(self, current_ver: str | version.Version) -> bool:
1✔
1139
        """
1140
        Determine if the provided version is the latest compared to the instance's version.
1141

1142
        This method compares the version of the current object with a given version to check
1143
        if the current version is equal to or earlier than the given version.
1144

1145
        :param current_ver: The version to compare against, it can be provided as a string or as a version.Version object.
1146
        :return: Returns True if the current version is less than or equal to the provided version (indicating it is the latest), False otherwise.
1147
        """
1148
        if isinstance(current_ver, str):
1✔
1149
            current_ver = version.parse(current_ver)
1✔
1150
        return self.version <= current_ver
1✔
1151

1152
    def get_asset(self, extension: str = '', file_name: str = '') -> Asset | None:
1✔
1153
        """
1154
        Retrieve the asset if its name matches the specified file extension and contains the given file name.
1155

1156
        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.
1157

1158
        :param extension: The file extension to check for.
1159
        :param file_name: The specific file name to look for within the asset's name.
1160
        :return: The Asset instance if the name matches, otherwise None.
1161
        """
1162
        try:
1✔
1163
            asset = next(asset for asset in self.assets if asset.get_asset_with_name(extension=extension, file_name=file_name) is not None)
1✔
1164
        except StopIteration:
1✔
1165
            asset = None
1✔
1166
        return asset
1✔
1167

1168
    def download_url(self, extension: str = '', file_name: str = '') -> str:
1✔
1169
        """
1170
        Download the URL of a specific asset that matches the given file name and extension.
1171

1172
        This method iterates through the list of assets, applying the criteria specified by
1173
        the `extension` and `file_name` parameters to identify the correct asset.
1174
        If no asset matches the provided criteria, an empty string is returned.
1175

1176
        :param extension: The file extension to search for, defaults to an empty string if not specified.
1177
        :param file_name: The file name to search for, defaults to an empty string if not specified.
1178
        :return: The download URL of the asset if a match is found, otherwise an empty string.
1179
        """
1180
        asset = self.get_asset(extension=extension, file_name=file_name)
1✔
1181
        if asset is not None:
1✔
1182
            return asset.browser_download_url
1✔
1183
        return ''
1✔
1184

1185
    @property
1✔
1186
    def version(self) -> version.Version:
1✔
1187
        """
1188
        The `version` property retrieves the software version as a `version.Version` object.
1189

1190
        The version data is parsed from the `tag_name` attribute, which is expected to be in a format compatible with `packaging.version`.
1191

1192
        :return: Parsed `Version` object representing the software version.
1193
        """
1194
        return version.parse(self.tag_name)
1✔
1195

1196
    @property
1✔
1197
    def published(self) -> str:
1✔
1198
        """
1199
        Convert and format the `published_at` attribute into a human-readable date string in the format 'DD Month YYYY'.
1200

1201
        :return: The formatted publication date string.
1202
        """
1203
        published = datetime.strptime(self.published_at, '%Y-%m-%dT%H:%M:%S%z').strftime('%d %B %Y')
1✔
1204
        return str(published)
1✔
1205

1206
    def __str__(self) -> str:
1✔
1207
        return f'{self.tag_name} pre:{self.prerelease} date:{self.published}'
1✔
1208

1209

1210
class RequestType(Enum):
1✔
1211
    """Internal request types."""
1212
    CYCLE = 'CYCLE'
1✔
1213
    CUSTOM = 'CUSTOM'
1✔
1214
    PUSH_BUTTON = 'PUSH_BUTTON'
1✔
1215

1216

1217
class RequestModel(BaseModel):
1✔
1218
    """
1219
    Represent a request model for handling different input button states and their respective BIOS actions.
1220

1221
    This class is designed to manage various types of input requests, including cycle, custom,
1222
    and push-button requests.
1223
    It provides functionality to validate input data, generate requests in byte format, and interpret requests based on specific conditions.
1224
    It also supports creating empty request models and handling interactions with BIOS configuration via designated callable functions.
1225
    """
1226
    ctrl_name: str
1✔
1227
    raw_request: str
1✔
1228
    get_bios_fn: Callable[[str], BiosValue]
1✔
1229
    cycle: CycleButton = CycleButton(ctrl_name='', step=0, max_value=0)
1✔
1230
    key: AnyButton
1✔
1231

1232
    @field_validator('ctrl_name')
1✔
1233
    def validate_interface(cls, value: str) -> str:
1✔
1234
        """
1235
        Validate the provided interface name ensuring it consists only of uppercase letters, digits, or underscores.
1236

1237
        This validator enforces strict naming conventions for control names, rejecting any value that contains invalid characters or is an empty string.
1238

1239
        :param value: The interface name to validate.
1240
        :return: The validated interface name if it passes all checks.
1241
        :raises ValueError: If the given value is an empty string or contains characters other than uppercase letters (A-Z), digits (0-9), or underscores (_).
1242
        """
1243
        if not value or not all(ch.isupper() or ch == '_' or ch.isdigit() for ch in value):
1✔
1244
            raise ValueError("Invalid value for 'ctrl_name'. Only A-Z, 0-9 and _ are allowed.")
×
1245
        return value
1✔
1246

1247
    @classmethod
1✔
1248
    def from_request(cls, key: AnyButton, request: str, get_bios_fn: Callable[[str], BiosValue]) -> RequestModel:
1✔
1249
        """
1250
        Create an instance of the RequestModel class using a specific request string.
1251

1252
        This method processes the provided request string to extract necessary
1253
        information, such as control name and cycle details.
1254
        It initializes a CycleButton instance using the request information if applicable.
1255
        The function then returns a RequestModel instance populated with the parsed data and additional state information.
1256

1257
        :param key: The key representing the `AnyButton` instance tied to the request.
1258
        :param request: The raw request string providing all request details.
1259
        :param get_bios_fn: A callable function that retrieves BIOS values, function takes
1260
                            a string input (BIOS key) and returns a corresponding `BiosValue` object.
1261
        :return: A new instance of `RequestModel` populated with data parsed from the provided request string and supporting parameters.
1262
        """
1263
        cycle_button = CycleButton(ctrl_name='', step=0, max_value=0)
1✔
1264
        if RequestType.CYCLE.value in request:
1✔
1265
            cycle_button = CycleButton.from_request(request)
1✔
1266
        ctrl_name = request.split(' ')[0]
1✔
1267
        return RequestModel(ctrl_name=ctrl_name, raw_request=request, get_bios_fn=get_bios_fn, cycle=cycle_button, key=key)
1✔
1268

1269
    @classmethod
1✔
1270
    def make_empty(cls, key: AnyButton) -> RequestModel:
1✔
1271
        """
1272
        Create an empty instance of RequestModel with default values for its attributes.
1273

1274
        :param key: Represents the key parameter, which will be used as a button object type for the RequestModel instance.
1275
        :return: A new instance of RequestModel initialized with default attribute values and the provided key parameter.
1276
        """
1277
        return cls(ctrl_name='EMPTY', raw_request='', get_bios_fn=int, cycle=CycleButton(ctrl_name='', step=0, max_value=0), key=key)
1✔
1278

1279
    def _get_next_value_for_button(self) -> int:
1✔
1280
        """
1281
        Determine the next value for the button using a ZigZagIterator.
1282

1283
        If the cycle iterator is not already an instance of ZigZagIterator, it initializes one
1284
        using the control name and cycle attributes, before returning the next value from the iterator.
1285

1286
        :raises TypeError: If ``self.cycle.iter`` is not of the expected type and cannot be initialized properly as a ZigZagIterator instance.
1287
        :returns: The next integer value generated by the ZigZagIterator.
1288
        """
1289
        if not isinstance(self.cycle.iter, ZigZagIterator):
1✔
1290
            self.cycle.iter = ZigZagIterator(current=int(self.get_bios_fn(self.ctrl_name)),
1✔
1291
                                             step=self.cycle.step,
1292
                                             max_val=self.cycle.max_value)
1293
        return next(self.cycle.iter)
1✔
1294

1295
    @property
1✔
1296
    def is_cycle(self) -> bool:
1✔
1297
        """
1298
        Check if the instance has a valid cycle.
1299

1300
        This property checks the internal state of the instance to determine whether a valid cycle exists.
1301
        A cycle is represented by the presence of a truthy value in the `cycle` attribute.
1302

1303
        :return: Returns ``True`` if a valid cycle exists, otherwise ``False``.
1304
        """
1305
        return bool(self.cycle)
1✔
1306

1307
    @property
1✔
1308
    def is_custom(self) -> bool:
1✔
1309
        """
1310
        Check if the request is of type custom.
1311

1312
        This property evaluates whether the raw_request attribute of the object contains
1313
        a custom request type, based on the predefined `RequestType.CUSTOM` value.
1314

1315
        :return: Boolean indicating if the request is of type custom.
1316
        """
1317
        return RequestType.CUSTOM.value in self.raw_request
1✔
1318

1319
    @property
1✔
1320
    def is_push_button(self) -> bool:
1✔
1321
        """
1322
        Identify if the request is a push-button type.
1323

1324
        This property checks if the raw_request contains a specific value indicating a push-button request type
1325
        and returns a boolean result accordingly.
1326

1327
        :return: True if the request is of type push-button, else False
1328
        """
1329
        return RequestType.PUSH_BUTTON.value in self.raw_request
1✔
1330

1331
    def bytes_requests(self, key_down: int | None = None) -> list[bytes]:
1✔
1332
        """
1333
        Generate and returns a list of byte strings based on a specific request input.
1334

1335
        The method generates a string request using the provided argument `key_down`.
1336
        It then splits the generated string request using the `|` delimiter and converts each segment into a byte string.
1337

1338
        :param key_down: Accepts an integer representing the key value or None for default behavior.
1339
        :return: A list containing byte strings derived from the generated request.
1340
        """
1341
        request = self._generate_request_based_on_case(key_down)
1✔
1342
        return [bytes(req, 'utf-8') for req in request.split('|')]
1✔
1343

1344
    def _generate_request_based_on_case(self, key_down: int | None = None) -> str:
1✔
1345
        """
1346
        Generate a formatted request string based on various conditions and cases.
1347

1348
        This method evaluates different scenarios using the `request_mapper` dictionary,
1349
        which maps integer case keys to specific conditions and methods.
1350
        If the condition for a given case is met, the corresponding method is called to generate the request.
1351
        If no conditions match, the raw request is returned appended with a newline.
1352

1353
        :param key_down: Integer representing a key state, it can be either a specific value such as `KEY_UP` or
1354
                         `None` for cases where a key down state is not applicable.
1355
        :return: Returns a string representing the generated request based on the active case conditions.
1356
        """
1357

1358
        class CaseDict(TypedDict):
1✔
1359
            condition: bool
1✔
1360
            method: partial
1✔
1361

1362
        request_mapper: dict[int, CaseDict] = {
1✔
1363
            1: {'condition': self.is_push_button and isinstance(self.key, Gkey),
1364
                'method': partial(self.__generate_push_btn_req_for_gkey_and_mouse, key_down)},
1365
            2: {'condition': self.is_push_button and isinstance(self.key, MouseButton),
1366
                'method': partial(self.__generate_push_btn_req_for_gkey_and_mouse, key_down)},
1367
            3: {'condition': self.is_push_button and isinstance(self.key, LcdButton),
1368
                'method': partial(self.__generate_push_btn_req_for_lcd_button)},
1369
            4: {'condition': key_down is None or key_down == KEY_UP,
1370
                'method': partial(RequestModel.__generate_empty)},
1371
            5: {'condition': self.is_cycle,
1372
                'method': partial(self.__generate_cycle_request)},
1373
            6: {'condition': self.is_custom,
1374
                'method': partial(self.__generate_custom_request)},
1375
        }
1376

1377
        for case in request_mapper.values():
1✔
1378
            if case['condition']:
1✔
1379
                return case['method']()
1✔
1380
        return f'{self.raw_request}\n'
1✔
1381

1382
    def __generate_push_btn_req_for_gkey_and_mouse(self, key_down: int | None) -> str:
1✔
1383
        """
1384
        Generate a string request for handling a push-button action for both keyboard keys and mouse events.
1385

1386
        The function constructs a command string based on the control name and whether a key is being pressed.
1387

1388
        :param key_down: Either an integer value representing the key being pressed or None if no key action is specified.
1389
        :return: A string formatted as a control name concatenated with the key_down value followed by a newline character.
1390
        """
1391
        return f'{self.ctrl_name} {key_down}\n'
1✔
1392

1393
    def __generate_push_btn_req_for_lcd_button(self) -> str:
1✔
1394
        """
1395
        Generate a push button request sequence for an LCD button.
1396

1397
        This method constructs and returns the string that represents the sequence of key press
1398
        events (key down followed by key up) for the LCD button associated with the `ctrl_name`.
1399
        The request is formatted as a string where each line corresponds to an event.
1400

1401
        :raises ValueError: If `ctrl_name` is not properly set or invalid.
1402
        :return: A string representing the key press sequence for the LCD button.
1403
        """
1404
        return f'{self.ctrl_name} {KEY_DOWN}\n|{self.ctrl_name} {KEY_UP}\n'
1✔
1405

1406
    @staticmethod
1✔
1407
    def __generate_empty() -> str:
1✔
1408
        """
1409
        Generate and return an empty string.
1410

1411
        It does not take any arguments and simply returns an empty string.
1412

1413
        :return: An empty string.
1414
        """
1415
        return ''
1✔
1416

1417
    def __generate_cycle_request(self) -> str:
1✔
1418
        """
1419
        Generate a cycle request string.
1420

1421
        This method constructs and returns a string representing a button's cycle request.
1422
        It typically combines the control name with the next value intended for the button and appends a newline character at the end.
1423

1424
        :return: A formatted string representing the cycle request, including the control name and the next value for the button.
1425
        """
1426
        return f'{self.ctrl_name} {self._get_next_value_for_button()}\n'
1✔
1427

1428
    def __generate_custom_request(self) -> str:
1✔
1429
        """
1430
        Generate and formats a custom request string based on the raw request input.
1431

1432
        This method processes the raw request string to extract and properly format its content,
1433
        specifically for custom request types.
1434
        It splits the raw request using the defined delimiter for custom request types and
1435
        re-formats the request content using newline characters.
1436

1437
        :raises AttributeError: If `self.raw_request` is not properly formatted or the expected split pattern is missing.
1438
        :raises IndexError: If the split raw request string does not contain the expected elements after processing.
1439
        :return: A formatted request string with replaced delimiters.
1440
        """
1441
        request = self.raw_request.split(f'{RequestType.CUSTOM.value} ')[1]
1✔
1442
        request = request.replace('|', '\n|')
1✔
1443
        return request.strip('|')
1✔
1444

1445
    def __str__(self) -> str:
1✔
1446
        return f'{self.ctrl_name}: {self.raw_request}'
1✔
1447

1448

1449
class Color(Enum):
1✔
1450
    """A superset of HTML 4.0 color names used in CSS 1."""
1451
    aliceblue = 0xf0f8ff
1✔
1452
    antiquewhite = 0xfaebd7
1✔
1453
    aqua = 0x00ffff
1✔
1454
    aquamarine = 0x7fffd4
1✔
1455
    azure = 0xf0ffff
1✔
1456
    beige = 0xf5f5dc
1✔
1457
    bisque = 0xffe4c4
1✔
1458
    black = 0x000000
1✔
1459
    blanchedalmond = 0xffebcd
1✔
1460
    blue = 0x0000ff
1✔
1461
    blueviolet = 0x8a2be2
1✔
1462
    brown = 0xa52a2a
1✔
1463
    burlywood = 0xdeb887
1✔
1464
    cadetblue = 0x5f9ea0
1✔
1465
    chartreuse = 0x7fff00
1✔
1466
    chocolate = 0xd2691e
1✔
1467
    coral = 0xff7f50
1✔
1468
    cornflowerblue = 0x6495ed
1✔
1469
    cornsilk = 0xfff8dc
1✔
1470
    crimson = 0xdc143c
1✔
1471
    cyan = 0x00ffff
1✔
1472
    darkblue = 0x00008b
1✔
1473
    darkcyan = 0x008b8b
1✔
1474
    darkgoldenrod = 0xb8860b
1✔
1475
    darkgray = 0xa9a9a9
1✔
1476
    darkgrey = 0xa9a9a9
1✔
1477
    darkgreen = 0x006400
1✔
1478
    darkkhaki = 0xbdb76b
1✔
1479
    darkmagenta = 0x8b008b
1✔
1480
    darkolivegreen = 0x556b2f
1✔
1481
    darkorange = 0xff8c00
1✔
1482
    darkorchid = 0x9932cc
1✔
1483
    darkred = 0x8b0000
1✔
1484
    darksalmon = 0xe9967a
1✔
1485
    darkseagreen = 0x8fbc8f
1✔
1486
    darkslateblue = 0x483d8b
1✔
1487
    darkslategray = 0x2f4f4f
1✔
1488
    darkslategrey = 0x2f4f4f
1✔
1489
    darkturquoise = 0x00ced1
1✔
1490
    darkviolet = 0x9400d3
1✔
1491
    deeppink = 0xff1493
1✔
1492
    deepskyblue = 0x00bfff
1✔
1493
    dimgray = 0x696969
1✔
1494
    dimgrey = 0x696969
1✔
1495
    dodgerblue = 0x1e90ff
1✔
1496
    firebrick = 0xb22222
1✔
1497
    floralwhite = 0xfffaf0
1✔
1498
    forestgreen = 0x228b22
1✔
1499
    fuchsia = 0xff00ff
1✔
1500
    gainsboro = 0xdcdcdc
1✔
1501
    ghostwhite = 0xf8f8ff
1✔
1502
    gold = 0xffd700
1✔
1503
    goldenrod = 0xdaa520
1✔
1504
    gray = 0x808080
1✔
1505
    grey = 0x808080
1✔
1506
    green = 0x008000
1✔
1507
    greenyellow = 0xadff2f
1✔
1508
    honeydew = 0xf0fff0
1✔
1509
    hotpink = 0xff69b4
1✔
1510
    indianred = 0xcd5c5c
1✔
1511
    indigo = 0x4b0082
1✔
1512
    ivory = 0xfffff0
1✔
1513
    khaki = 0xf0e68c
1✔
1514
    lavender = 0xe6e6fa
1✔
1515
    lavenderblush = 0xfff0f5
1✔
1516
    lawngreen = 0x7cfc00
1✔
1517
    lemonchiffon = 0xfffacd
1✔
1518
    lightblue = 0xadd8e6
1✔
1519
    lightcoral = 0xf08080
1✔
1520
    lightcyan = 0xe0ffff
1✔
1521
    lightgoldenrodyellow = 0xfafad2
1✔
1522
    lightgreen = 0x90ee90
1✔
1523
    lightgray = 0xd3d3d3
1✔
1524
    lightgrey = 0xd3d3d3
1✔
1525
    lightpink = 0xffb6c1
1✔
1526
    lightsalmon = 0xffa07a
1✔
1527
    lightseagreen = 0x20b2aa
1✔
1528
    lightskyblue = 0x87cefa
1✔
1529
    lightslategray = 0x778899
1✔
1530
    lightslategrey = 0x778899
1✔
1531
    lightsteelblue = 0xb0c4de
1✔
1532
    lightyellow = 0xffffe0
1✔
1533
    lime = 0x00ff00
1✔
1534
    limegreen = 0x32cd32
1✔
1535
    linen = 0xfaf0e6
1✔
1536
    magenta = 0xff00ff
1✔
1537
    maroon = 0x800000
1✔
1538
    mediumaquamarine = 0x66cdaa
1✔
1539
    mediumblue = 0x0000cd
1✔
1540
    mediumorchid = 0xba55d3
1✔
1541
    mediumpurple = 0x9370db
1✔
1542
    mediumseagreen = 0x3cb371
1✔
1543
    mediumslateblue = 0x7b68ee
1✔
1544
    mediumspringgreen = 0x00fa9a
1✔
1545
    mediumturquoise = 0x48d1cc
1✔
1546
    mediumvioletred = 0xc71585
1✔
1547
    midnightblue = 0x191970
1✔
1548
    mintcream = 0xf5fffa
1✔
1549
    mistyrose = 0xffe4e1
1✔
1550
    moccasin = 0xffe4b5
1✔
1551
    navajowhite = 0xffdead
1✔
1552
    navy = 0x000080
1✔
1553
    oldlace = 0xfdf5e6
1✔
1554
    olive = 0x808000
1✔
1555
    olivedrab = 0x6b8e23
1✔
1556
    orange = 0xffa500
1✔
1557
    orangered = 0xff4500
1✔
1558
    orchid = 0xda70d6
1✔
1559
    palegoldenrod = 0xeee8aa
1✔
1560
    palegreen = 0x98fb98
1✔
1561
    paleturquoise = 0xafeeee
1✔
1562
    palevioletred = 0xdb7093
1✔
1563
    papayawhip = 0xffefd5
1✔
1564
    peachpuff = 0xffdab9
1✔
1565
    peru = 0xcd853f
1✔
1566
    pink = 0xffc0cb
1✔
1567
    plum = 0xdda0dd
1✔
1568
    powderblue = 0xb0e0e6
1✔
1569
    purple = 0x800080
1✔
1570
    rebeccapurple = 0x663399
1✔
1571
    red = 0xff0000
1✔
1572
    rosybrown = 0xbc8f8f
1✔
1573
    royalblue = 0x4169e1
1✔
1574
    saddlebrown = 0x8b4513
1✔
1575
    salmon = 0xfa8072
1✔
1576
    sandybrown = 0xf4a460
1✔
1577
    seagreen = 0x2e8b57
1✔
1578
    seashell = 0xfff5ee
1✔
1579
    sienna = 0xa0522d
1✔
1580
    silver = 0xc0c0c0
1✔
1581
    skyblue = 0x87ceeb
1✔
1582
    slateblue = 0x6a5acd
1✔
1583
    slategray = 0x708090
1✔
1584
    slategrey = 0x708090
1✔
1585
    snow = 0xfffafa
1✔
1586
    springgreen = 0x00ff7f
1✔
1587
    steelblue = 0x4682b4
1✔
1588
    tan = 0xd2b48c
1✔
1589
    teal = 0x008080
1✔
1590
    thistle = 0xd8bfd8
1✔
1591
    tomato = 0xff6347
1✔
1592
    turquoise = 0x40e0d0
1✔
1593
    violet = 0xee82ee
1✔
1594
    wheat = 0xf5deb3
1✔
1595
    white = 0xffffff
1✔
1596
    whitesmoke = 0xf5f5f5
1✔
1597
    yellow = 0xffff00
1✔
1598
    yellowgreen = 0x9acd32
1✔
1599

1600

1601
class GuiTab(IntEnum):
1✔
1602
    """Describe GUI mani window tabs."""
1603
    devices = 0
1✔
1604
    settings = 1
1✔
1605
    g_keys = 2
1✔
1606
    debug = 3
1✔
1607

1608

1609
class DllSdk(BaseModel):
1✔
1610
    """DLL SDK."""
1611
    name: str
1✔
1612
    header_file: str
1✔
1613
    directory: str
1✔
1614

1615
    @property
1✔
1616
    def header(self) -> str:
1✔
1617
        """
1618
        Load the header content of the DLL.
1619

1620
        :return: The header content as a string.
1621
        """
1622
        with open(file=Path(__file__) / '..' / 'resources' / f'{self.header_file}') as header_file:
1✔
1623
            header = header_file.read()
1✔
1624
        return header
1✔
1625

1626
    def get_path(self) -> str:
1✔
1627
        """
1628
        Return the path of the DLL file based on the provided library type.
1629

1630
        :return: The path of the DLL file as a string.
1631
        """
1632
        arch = 'x64' if all([architecture()[0] == '64bit', maxsize > 2 ** 32, sizeof(c_void_p) > 4]) else 'x86'
1✔
1633
        try:
1✔
1634
            prog_files = environ['PROGRAMW6432']
1✔
1635
        except KeyError:
×
1636
            prog_files = environ['PROGRAMFILES']
×
1637
        dll_path = f'{prog_files}\\Logitech Gaming Software\\SDK\\{self.directory}\\{arch}\\Logitech{self.name.capitalize()}.dll'
1✔
1638
        return dll_path
1✔
1639

1640

1641
LcdDll = DllSdk(name='LCD', directory='LCD', header_file='LogitechLCDLib.h')
1✔
1642
LedDll = DllSdk(name='LED', directory='LED', header_file='LogitechLEDLib.h')
1✔
1643
KeyDll = DllSdk(name='Gkey', directory='G-key', header_file='LogitechGkeyLib.h')
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc