diff --git a/changelog.md b/changelog.md index 031ba808..259e401d 100644 --- a/changelog.md +++ b/changelog.md @@ -35,6 +35,8 @@ * **[UI/UX]** Sync package waypoints when primary flight's waypoints are updated and recreate other flights within the package to ensure JOIN, INGRESS & SPLIT are synced * **[UI/UX]** Allow changing loadout on flight creation * **[UI]** Display TOT for all waypoints in the flight plan +* **[UI]** Edit basic datalink properties for applicable aircraft +* **[Mission Generation]** Automatic datalink network setup for applicable aircraft (_should_ in theory avoid the need to re-save the mission) ## Fixes * **[UI/UX]** A-10A flights can be edited again diff --git a/game/ato/flightmember.py b/game/ato/flightmember.py index a948dd41..84ce9517 100644 --- a/game/ato/flightmember.py +++ b/game/ato/flightmember.py @@ -16,7 +16,7 @@ class FlightMember: self.use_custom_loadout = False self.tgp_laser_code: LaserCode | None = None self.weapon_laser_code: LaserCode | None = None - self.properties: dict[str, bool | float | int] = {} + self.properties: dict[str, bool | float | int | str] = {} self.livery: Optional[str] = None self.use_livery_set: bool = True diff --git a/game/missiongenerator/aircraft/aircraftgenerator.py b/game/missiongenerator/aircraft/aircraftgenerator.py index 0370b9bb..4ae61076 100644 --- a/game/missiongenerator/aircraft/aircraftgenerator.py +++ b/game/missiongenerator/aircraft/aircraftgenerator.py @@ -9,6 +9,9 @@ from dcs import Point from dcs.action import AITaskPush from dcs.condition import FlagIsTrue, GroupDead, Or, FlagIsFalse from dcs.country import Country +from dcs.datalinks.datalink import DataLinkType +from dcs.datalinks.datalinkbase import DataLinkSettingsWithFlightLead +from dcs.datalinks.link16 import Link16Network, ViperLink16NetworkMemberLink from dcs.mission import Mission from dcs.terrain.terrain import NoParkingSlotError from dcs.triggers import TriggerOnce, Event @@ -37,6 +40,7 @@ from .flightdata import FlightData from .flightgroupconfigurator import FlightGroupConfigurator from .flightgroupspawner import FlightGroupSpawner from ...data.weapons import WeaponType +from ...radio.datalink import DataLinkRegistry if TYPE_CHECKING: from game import Game @@ -52,6 +56,7 @@ class AircraftGenerator: time: datetime, radio_registry: RadioRegistry, tacan_registry: TacanRegistry, + datalink_registry: DataLinkRegistry, unit_map: UnitMap, mission_data: MissionData, helipads: dict[ControlPoint, list[StaticGroup]], @@ -65,6 +70,7 @@ class AircraftGenerator: self.time = time self.radio_registry = radio_registry self.tacan_registy = tacan_registry + self.datalink_registry = datalink_registry self.unit_map = unit_map self.flights: List[FlightData] = [] self.mission_data = mission_data @@ -115,6 +121,7 @@ class AircraftGenerator: dynamic_runways: Runway data for carriers and FARPs. """ self._reserve_frequencies_and_tacan(ato) + self.mission_data.packages.clear() for package in reversed(sorted(ato.packages, key=lambda x: x.time_over_target)): logging.info(f"Generating package for target: {package.target.name}") @@ -153,6 +160,39 @@ class AircraftGenerator: if len(splittrigger.actions) > 0: self.mission.triggerrules.triggers.append(splittrigger) + # at this point all flights were generated, so now start setting up datalink... + self._link_datalink_on_package_level_and_awacs() + + def _link_datalink_on_package_level_and_awacs(self) -> None: + for _, flights in self.mission_data.packages.items(): + for f in flights: + if not f.aircraft_type.dcs_unit_type.networked_datalink: + continue + for awacs in self.mission_data.awacs: + if awacs.blue == f.friendly: + for u in f.units: + assert u.datalink is not None + if u.datalink.link_type == DataLinkType.LINK16: + u.datalink.network.add_donor(awacs.unit.id) + for unit in f.units: + assert unit.datalink is not None + link_type = unit.datalink.link_type + # check if there's room as team members + for package_flight in flights: + dcs_type = package_flight.aircraft_type.dcs_unit_type + if f is package_flight or not dcs_type.networked_datalink: + continue + default_link = dcs_type.get_default_datalink() + if default_link and link_type != default_link.link_type: + continue + pf_lead = package_flight.units[0] + if not ( + unit.datalink.network.has_donors + and unit.datalink.network.add_donor(pf_lead.id) + or unit.datalink.network.add_member(pf_lead.id) + ): + break + def spawn_unused_aircraft( self, player_country: Country, enemy_country: Country ) -> None: @@ -246,26 +286,58 @@ class AircraftGenerator: self.ground_spawns, self.mission_data, ).create_flight_group() - self.flights.append( - FlightGroupConfigurator( - flight, - group, - self.game, - self.mission, - self.time, - self.radio_registry, - self.tacan_registy, - self.mission_data, - dynamic_runways, - self.use_client, - ).configure() - ) + + flight_data = FlightGroupConfigurator( + flight, + group, + self.game, + self.mission, + self.time, + self.radio_registry, + self.tacan_registy, + self.datalink_registry, + self.mission_data, + dynamic_runways, + self.use_client, + ).configure() + + self.flights.append(flight_data) + + if not self.mission_data.packages.get(id(flight.package)): + self.mission_data.packages[id(flight.package)] = [] + self.mission_data.packages[id(flight.package)].append(flight_data) + + dcs_type = flight.unit_type.dcs_unit_type + if dcs_type.networked_datalink: + self.setup_internal_datalink_network(group) if self.ewrj: self._track_ewrj_flight(flight, group) return group + @staticmethod + def setup_internal_datalink_network(group: FlyingGroup[Any]) -> None: + link = group.units[0].datalink + if not link: + return + if isinstance(link.settings, DataLinkSettingsWithFlightLead): + link.settings.flight_lead = True + for u1 in group.units: + assert u1.datalink is not None + for u2 in group.units: + if u1 is u2: + continue + u1.datalink.network.add_member(u2.id) + net = u1.datalink.network + if ( + isinstance(net, Link16Network) + and net.member_link_type == ViperLink16NetworkMemberLink + ): + for member_link in net.team_members: + assert isinstance(member_link, ViperLink16NetworkMemberLink) + member_link.tdoa = True + def _track_ewrj_flight(self, flight: Flight, group: FlyingGroup[Any]) -> None: if not self.ewrj_package_dict.get(id(flight.package)): self.ewrj_package_dict[id(flight.package)] = [] diff --git a/game/missiongenerator/aircraft/flightgroupconfigurator.py b/game/missiongenerator/aircraft/flightgroupconfigurator.py index 4e815d28..d8c2a103 100644 --- a/game/missiongenerator/aircraft/flightgroupconfigurator.py +++ b/game/missiongenerator/aircraft/flightgroupconfigurator.py @@ -37,6 +37,14 @@ from ...ato.flightmember import FlightMember from ...ato.flightplans.aewc import AewcFlightPlan from ...ato.flightplans.packagerefueling import PackageRefuelingFlightPlan from ...ato.flightplans.theaterrefueling import TheaterRefuelingFlightPlan +from ...radio.datalink import ( + DataLinkRegistry, + DataLinkKey, + DataLinkIdentifier, + VOICE_CALLSIGN_LABEL, + VOICE_CALLSIGN_NUMBER, + OWNSHIP_CALLSIGN, +) from ...theater import Fob if TYPE_CHECKING: @@ -53,6 +61,7 @@ class FlightGroupConfigurator: time: datetime, radio_registry: RadioRegistry, tacan_registry: TacanRegistry, + datalink_registry: DataLinkRegistry, mission_data: MissionData, dynamic_runways: dict[str, RunwayData], use_client: bool, @@ -64,6 +73,7 @@ class FlightGroupConfigurator: self.time = time self.radio_registry = radio_registry self.tacan_registry = tacan_registry + self.datalink_registry = datalink_registry self.mission_data = mission_data self.dynamic_runways = dynamic_runways self.use_client = use_client @@ -209,6 +219,7 @@ class FlightGroupConfigurator: start_time=self.flight.flight_plan.patrol_start_time, end_time=self.flight.flight_plan.patrol_end_time, blue=self.flight.departure.captured, + unit=self.group.units[0], ) ) elif isinstance( @@ -276,14 +287,50 @@ class FlightGroupConfigurator: return levels[new_level] def setup_props(self) -> None: + unit: FlyingUnit + member: FlightMember for unit, member in zip(self.group.units, self.flight.iter_members()): props = dict(member.properties) if (code := member.weapon_laser_code) is not None: for laser_code_config in self.flight.unit_type.laser_code_configs: props.update(laser_code_config.property_dict_for_code(code.code)) + if unit.unit_type.datalink_networkable() and self.no_datalink_set(props): + self.set_datalink(props, unit.callsign_as_str()) for prop_id, value in props.items(): unit.set_property(prop_id, value) + @staticmethod + def no_datalink_set(props: dict[str, bool | float | int | str]) -> bool: + for type in DataLinkKey: + if type.value in props: + return False + return True + + def set_datalink( + self, props: dict[str, bool | float | int | str], callsign: str + ) -> None: + vcl = callsign[:-2][0] + callsign[:-2][-1] + vcn = callsign[-2:] + identifier = self.datalink_registry.alloc_for_aircraft(self.flight.unit_type) + self.set_datalink_props(props, vcl.upper(), vcn, identifier) + + @staticmethod + def set_datalink_props( + props: dict[str, bool | float | int | str], + label: str, + number: str, + identifier: DataLinkIdentifier, + ) -> None: + if identifier.type in [DataLinkKey.LINK16, DataLinkKey.SADL]: + if not props.get(VOICE_CALLSIGN_LABEL): + props[VOICE_CALLSIGN_LABEL] = label + if not props.get(VOICE_CALLSIGN_NUMBER): + props[VOICE_CALLSIGN_NUMBER] = number + elif identifier.type in [DataLinkKey.IDM]: + if not props.get(OWNSHIP_CALLSIGN): + props[OWNSHIP_CALLSIGN] = f"G-{identifier.id}" + props[identifier.type.value] = identifier.id + def setup_payloads(self) -> None: for unit, member in zip(self.group.units, self.flight.iter_members()): self.setup_payload(unit, member) diff --git a/game/missiongenerator/missiondata.py b/game/missiongenerator/missiondata.py index 5b7b1447..83fdda07 100644 --- a/game/missiongenerator/missiondata.py +++ b/game/missiongenerator/missiondata.py @@ -4,6 +4,8 @@ from dataclasses import dataclass, field from datetime import datetime from typing import Optional, TYPE_CHECKING +from dcs.flyingunit import FlyingUnit + from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType from game.missiongenerator.aircraft.flightdata import FlightData @@ -36,6 +38,7 @@ class AwacsInfo(GroupInfo): depature_location: Optional[str] start_time: datetime end_time: datetime + unit: FlyingUnit # reference to be used as L16 donor @dataclass @@ -102,6 +105,7 @@ class MissionData: runways: list[RunwayData] = field(default_factory=list) carriers: list[CarrierInfo] = field(default_factory=list) flights: list[FlightData] = field(default_factory=list) + packages: dict[int, list[FlightData]] = field(default_factory=dict) tankers: list[TankerInfo] = field(default_factory=list) jtacs: list[JtacInfo] = field(default_factory=list) logistics: list[LogisticsInfo] = field(default_factory=list) diff --git a/game/missiongenerator/missiongenerator.py b/game/missiongenerator/missiongenerator.py index 3426d8bc..f6d5de7e 100644 --- a/game/missiongenerator/missiongenerator.py +++ b/game/missiongenerator/missiongenerator.py @@ -40,6 +40,7 @@ from .tgogenerator import TgoGenerator from .triggergenerator import TriggerGenerator from .visualsgenerator import VisualsGenerator from ..radio.TacanContainer import TacanContainer +from ..radio.datalink import DataLinkRegistry if TYPE_CHECKING: from game import Game @@ -56,6 +57,7 @@ class MissionGenerator: self.radio_registry = RadioRegistry() self.tacan_registry = TacanRegistry() + self.datalink_registry = DataLinkRegistry() self.generation_started = False @@ -247,6 +249,7 @@ class MissionGenerator: self.time, self.radio_registry, self.tacan_registry, + self.datalink_registry, self.unit_map, mission_data=self.mission_data, helipads=tgo_generator.helipads, diff --git a/game/pretense/pretenseaircraftgenerator.py b/game/pretense/pretenseaircraftgenerator.py index e297901f..20028fdc 100644 --- a/game/pretense/pretenseaircraftgenerator.py +++ b/game/pretense/pretenseaircraftgenerator.py @@ -28,6 +28,7 @@ from game.pretense.pretenseflightgroupconfigurator import ( PretenseFlightGroupConfigurator, ) from game.pretense.pretenseflightgroupspawner import PretenseNameGenerator +from game.radio.datalink import DataLinkRegistry from game.radio.radios import RadioRegistry from game.radio.tacan import TacanRegistry from game.runways import RunwayData @@ -64,6 +65,7 @@ class PretenseAircraftGenerator: time: datetime, radio_registry: RadioRegistry, tacan_registry: TacanRegistry, + datalink_registry: DataLinkRegistry, laser_code_registry: LaserCodeRegistry, unit_map: UnitMap, mission_data: MissionData, @@ -78,6 +80,7 @@ class PretenseAircraftGenerator: self.time = time self.radio_registry = radio_registry self.tacan_registy = tacan_registry + self.datalink_registry = datalink_registry self.laser_code_registry = laser_code_registry self.unit_map = unit_map self.flights: List[FlightData] = [] @@ -1032,6 +1035,7 @@ class PretenseAircraftGenerator: self.time, self.radio_registry, self.tacan_registy, + self.datalink_registry, self.mission_data, dynamic_runways, self.use_client, diff --git a/game/pretense/pretenseflightgroupconfigurator.py b/game/pretense/pretenseflightgroupconfigurator.py index 9726c707..02226e00 100644 --- a/game/pretense/pretenseflightgroupconfigurator.py +++ b/game/pretense/pretenseflightgroupconfigurator.py @@ -12,7 +12,6 @@ from game.ato.flightmember import FlightMember from game.ato.flightwaypoint import FlightWaypoint from game.ato.flightwaypointtype import FlightWaypointType from game.data.weapons import Pylon -from game.lasercodes.lasercoderegistry import LaserCodeRegistry from game.missiongenerator.aircraft.aircraftbehavior import AircraftBehavior from game.missiongenerator.aircraft.aircraftpainter import AircraftPainter from game.missiongenerator.aircraft.bingoestimator import BingoEstimator @@ -25,6 +24,7 @@ from game.missiongenerator.aircraft.waypoints.pydcswaypointbuilder import ( PydcsWaypointBuilder, ) from game.missiongenerator.missiondata import MissionData +from game.radio.datalink import DataLinkRegistry from game.radio.radios import RadioRegistry from game.radio.tacan import ( TacanRegistry, @@ -45,6 +45,7 @@ class PretenseFlightGroupConfigurator(FlightGroupConfigurator): time: datetime, radio_registry: RadioRegistry, tacan_registry: TacanRegistry, + datalink_registry: DataLinkRegistry, mission_data: MissionData, dynamic_runways: dict[str, RunwayData], use_client: bool, @@ -57,6 +58,7 @@ class PretenseFlightGroupConfigurator(FlightGroupConfigurator): time, radio_registry, tacan_registry, + datalink_registry, mission_data, dynamic_runways, use_client, @@ -69,6 +71,7 @@ class PretenseFlightGroupConfigurator(FlightGroupConfigurator): self.time = time self.radio_registry = radio_registry self.tacan_registry = tacan_registry + self.datalink_registry = datalink_registry self.mission_data = mission_data self.dynamic_runways = dynamic_runways self.use_client = use_client diff --git a/game/pretense/pretensemissiongenerator.py b/game/pretense/pretensemissiongenerator.py index 221e10e6..1f5b4859 100644 --- a/game/pretense/pretensemissiongenerator.py +++ b/game/pretense/pretensemissiongenerator.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import TYPE_CHECKING import dcs.lua -from dcs import Mission, Point +from dcs import Point from dcs.coalition import Coalition from dcs.countries import ( country_dict, @@ -16,21 +16,18 @@ from dcs.countries import ( ) from dcs.task import AFAC, FAC, SetInvisibleCommand, SetImmortalCommand, OrbitAction -from game.lasercodes.lasercoderegistry import LaserCodeRegistry from game.missiongenerator.convoygenerator import ConvoyGenerator from game.missiongenerator.environmentgenerator import EnvironmentGenerator from game.missiongenerator.forcedoptionsgenerator import ForcedOptionsGenerator from game.missiongenerator.frontlineconflictdescription import ( FrontLineConflictDescription, ) -from game.missiongenerator.missiondata import MissionData, JtacInfo +from game.missiongenerator.missiondata import JtacInfo from game.missiongenerator.tgogenerator import TgoGenerator from game.missiongenerator.visualsgenerator import VisualsGenerator from game.naming import namegen from game.persistency import pre_pretense_backups_dir from game.pretense.pretenseaircraftgenerator import PretenseAircraftGenerator -from game.radio.radios import RadioRegistry -from game.radio.tacan import TacanRegistry from game.theater.bullseye import Bullseye from game.unitmap import UnitMap from qt_ui.windows.GameUpdateSignal import GameUpdateSignal @@ -40,6 +37,7 @@ from .pretensetriggergenerator import PretenseTriggerGenerator from ..ato.airtaaskingorder import AirTaskingOrder from ..callsigns import callsign_for_support_unit from ..dcs.aircrafttype import AircraftType +from ..lasercodes import LaserCodeRegistry from ..missiongenerator import MissionGenerator from ..theater import Airfield @@ -50,21 +48,8 @@ if TYPE_CHECKING: class PretenseMissionGenerator(MissionGenerator): def __init__(self, game: Game, time: datetime) -> None: super().__init__(game, time) - self.game = game - self.time = time - self.mission = Mission(game.theater.terrain) - self.unit_map = UnitMap() - - self.mission_data = MissionData() self.laser_code_registry = LaserCodeRegistry() - self.radio_registry = RadioRegistry() - self.tacan_registry = TacanRegistry() - - self.generation_started = False - - self.p_country = country_dict[self.game.blue.faction.country.id]() - self.e_country = country_dict[self.game.red.faction.country.id]() with open("resources/default_options.lua", "r", encoding="utf-8") as f: options = dcs.lua.loads(f.read())["options"] @@ -262,6 +247,7 @@ class PretenseMissionGenerator(MissionGenerator): self.time, self.radio_registry, self.tacan_registry, + self.datalink_registry, self.laser_code_registry, self.unit_map, mission_data=self.mission_data, diff --git a/game/radio/datalink.py b/game/radio/datalink.py new file mode 100644 index 00000000..9caa53ac --- /dev/null +++ b/game/radio/datalink.py @@ -0,0 +1,107 @@ +"""DATALINK handling.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Dict, Iterator, Set + +from game.dcs.aircrafttype import AircraftType + +VOICE_CALLSIGN_LABEL = "VoiceCallsignLabel" +VOICE_CALLSIGN_NUMBER = "VoiceCallsignNumber" +OWNSHIP_CALLSIGN = "OwnshipCallSign" + + +class DataLinkKey(Enum): + LINK16 = "STN_L16" + SADL = "SADL_TN" + IDM = "TN_IDM_LB" + Unknown = "Unsupported type" + + @staticmethod + def from_aircraft_type(ac_type: AircraftType) -> DataLinkKey: + dcs_type = ac_type.dcs_unit_type + if DataLinkKey.LINK16.value in dcs_type.properties: + return DataLinkKey.LINK16 + elif DataLinkKey.SADL.value in dcs_type.properties: + return DataLinkKey.SADL + elif DataLinkKey.IDM.value in dcs_type.properties: + return DataLinkKey.IDM + return DataLinkKey.Unknown + + def range(self) -> Iterator["DataLinkIdentifier"]: + match self.value: + case DataLinkKey.LINK16.value: + return ( + DataLinkIdentifier(str(f"{x:05o}"), self) for x in range(1, 0o77777) + ) + case DataLinkKey.SADL.value: + return ( + DataLinkIdentifier(str(f"{x:04o}"), self) for x in range(1, 0o7777) + ) + case DataLinkKey.IDM.value: + return (DataLinkIdentifier(x, self) for x in self._idm_ids()) + + raise RuntimeError(f"No range for datalink-type: {self.value}") + + def valid_identifiers(self) -> Iterator["DataLinkIdentifier"]: + for x in self.range(): + yield x + + @staticmethod + def _idm_ids() -> Iterator[str]: # TODO: there must be a better place for this... + second_range = [str(x) for x in range(1, 10)] + for single in second_range: + yield f"{single}" + for first in range(1, 4): + second_range = [str(x) for x in range(1, 10)] + if first < 3: + second_range.extend([str(chr(x)) for x in range(65, 91)]) + else: + second_range.extend([str(chr(x)) for x in range(65, 74)]) + for second in second_range: + yield f"{first}{second}" + + +@dataclass +class DataLinkIdentifier: + id: str + type: DataLinkKey + + def __hash__(self) -> int: + return f"{self.id} - {self.type.value}".__hash__() + + +class OutOfIdentifiersError(RuntimeError): + """Raised when all channels in this band have been allocated.""" + + def __init__(self, type: DataLinkKey) -> None: + super().__init__( + f"No available identifiers left for datalink-type {type.value}" + ) + + +class DataLinkRegistry: + """Manages allocation of DATALINK identifiers.""" + + def __init__(self) -> None: + self.allocated_identifiers: Set[DataLinkIdentifier] = set() + self.allocators: Dict[DataLinkKey, Iterator[DataLinkIdentifier]] = {} + + for type in DataLinkKey: + self.allocators[type] = type.valid_identifiers() + + def alloc_for_aircraft(self, ac_type: AircraftType) -> DataLinkIdentifier: + datalink_type = DataLinkKey.from_aircraft_type(ac_type) + allocator = self.allocators[datalink_type] + try: + while (identifier := next(allocator)) in self.allocated_identifiers: + pass + self.mark_unavailable(identifier) + return identifier + except StopIteration: + raise OutOfIdentifiersError(datalink_type) + + def mark_unavailable(self, identifier: DataLinkIdentifier) -> None: + self.allocated_identifiers.add(identifier) diff --git a/qt_ui/windows/mission/flight/payload/propertyeditbox.py b/qt_ui/windows/mission/flight/payload/propertyeditbox.py new file mode 100644 index 00000000..54b66a0b --- /dev/null +++ b/qt_ui/windows/mission/flight/payload/propertyeditbox.py @@ -0,0 +1,145 @@ +import re +from typing import Dict, Callable, Union + +from PySide6.QtGui import QFocusEvent +from PySide6.QtWidgets import QLineEdit +from dcs.unitpropertydescription import UnitPropertyDescription + +from game.ato import Flight +from game.ato.flightmember import FlightMember +from game.radio.datalink import DataLinkKey + + +class PropertyEditBox(QLineEdit): + def __init__( + self, + flight_member: FlightMember, + prop: UnitPropertyDescription, + flight: Flight, + ) -> None: + super().__init__() + self.flight_member = flight_member + self.prop = prop + self.flight = flight + + self.setFixedWidth(100) + self.setText( + self.flight_member.properties.get(self.prop.identifier, self.prop.default) + ) + + self.textChanged.connect(self.on_value_changed) + + self._datalink_handlers: Dict[ + DataLinkKey, + Union[Callable[[int, str], str], Callable[[int, str, bool], str]], + ] = { + DataLinkKey.LINK16: self._link16_input, + DataLinkKey.SADL: self._sadl_input, + DataLinkKey.IDM: self._idm_input, + } + + @property + def datalink_type(self) -> DataLinkKey: + return DataLinkKey.from_aircraft_type(self.flight.unit_type) + + def focusOutEvent(self, e: QFocusEvent) -> None: + super(PropertyEditBox, self).focusOutEvent(e) + link_type = self.datalink_type + if (handler := self._datalink_handlers.get(link_type)) is not None: + value = handler(0, self.text(), True) + self.setText(value) + + def on_value_changed(self, value: str) -> None: + cursor = self.cursorPosition() + link_type = self.datalink_type + if (handler := self._datalink_handlers.get(link_type)) is not None: + value = handler(cursor, value.upper()) + if not value and self.prop.identifier in self.flight_member.properties: + del self.flight_member.properties[self.prop.identifier] + else: + self.flight_member.properties[self.prop.identifier] = value + self.setText(value) + self.setCursorPosition(cursor) + if cursor > len(value): + self.setCursorPosition(0) + + def _link16_input(self, cursor: int, value: str, focus_lost: bool = False) -> str: + max_input_length = 5 + if "VoiceCallsign" in self.prop.identifier: + max_input_length = 2 + value = self.common_link16_sadl_logic( + cursor, focus_lost, max_input_length, "STN_L16", value + ) + return value + + def common_link16_sadl_logic( + self, + cursor: int, + focus_lost: bool, + max_input_length: int, + identifier: str, + value: str, + ) -> str: + value = value[:max_input_length] + if not ( + ("Label" in self.prop.identifier and value.isalpha()) + or ("Number" in self.prop.identifier and value.isdigit()) + or (identifier == self.prop.identifier and self._is_octal(value)) + ): + value = self._restore_from_property(cursor) + if focus_lost and 2 < max_input_length > len(value): + # STN_L16 --> prepend with zeroes + value = "0" * (max_input_length - len(value)) + value + return value + + def _restore_from_property(self, cursor): + props = self.flight_member.properties + value = props.get(self.prop.identifier, "")[: cursor - 1] + return value + + @staticmethod + def _is_octal(value: str) -> bool: + if re.match(r"^[0-7]+$", value): + return True + return False + + def _sadl_input(self, cursor: int, value: str, focus_lost: bool = False) -> str: + max_input_length = 4 + if "VoiceCallsign" in self.prop.identifier: + max_input_length = 2 + value = self.common_link16_sadl_logic( + cursor, focus_lost, max_input_length, "SADL_TN", value + ) + return value + + def _idm_input(self, cursor: int, value: str, _: bool = False) -> str: + if "TN_IDM_LB" == self.prop.identifier: + value = value[:2] # Originator ID + if len(value) == 1 and not value.isalnum(): + return "" + elif len(value) == 2 and not self._valid_2char_idm(value): + value = str(value[0]) + else: + value = value[:5] # Ownship CallSign + if not re.match(r"^[A-Z0-9/*\-+.]{,5}$", value): + value = self._restore_from_property(cursor) + return value + + @staticmethod + def _valid_2char_idm(value: str) -> bool: + if len(value) != 2: + return False + first = value[0] + second = value[1] + return ( + first in ["1", "2"] + and second.isalnum() + or first == "3" + and (re.match(r"^[A-I]$", second) or second.isdigit()) + ) + + def set_flight_member(self, flight_member: FlightMember) -> None: + self.flight_member = flight_member + self.setText( + self.flight_member.properties.get(self.prop.identifier, self.prop.default) + ) diff --git a/qt_ui/windows/mission/flight/payload/propertyeditor.py b/qt_ui/windows/mission/flight/payload/propertyeditor.py index f4aa3d06..271c2a20 100644 --- a/qt_ui/windows/mission/flight/payload/propertyeditor.py +++ b/qt_ui/windows/mission/flight/payload/propertyeditor.py @@ -11,6 +11,7 @@ from game.ato.flightmember import FlightMember from .missingpropertydataerror import MissingPropertyDataError from .propertycheckbox import PropertyCheckBox from .propertycombobox import PropertyComboBox +from .propertyeditbox import PropertyEditBox from .propertyspinbox import PropertySpinBox @@ -22,6 +23,7 @@ class UnhandledControlTypeError(RuntimeError): class PropertyEditor(QGridLayout): def __init__(self, flight: Flight, flight_member: FlightMember) -> None: super().__init__() + self.flight = flight self.flight_member = flight_member self.flight_member_update_listeners: list[Callable[[FlightMember], None]] = [] @@ -78,7 +80,7 @@ class PropertyEditor(QGridLayout): def control_for_property(self, prop: UnitPropertyDescription) -> Optional[QWidget]: # Valid values are: - # "checkbox", "comboList", "groupbox", "label", "slider", "spinbox" + # "checkbox", "comboList", "groupbox", "label", "slider", "spinbox", "editbox" match prop.control: case "checkbox": widget = PropertyCheckBox(self.flight_member, prop) @@ -94,6 +96,10 @@ class PropertyEditor(QGridLayout): widget = PropertySpinBox(self.flight_member, prop) self.flight_member_update_listeners.append(widget.set_flight_member) return widget + case "editbox": + widget = PropertyEditBox(self.flight_member, prop, self.flight) + self.flight_member_update_listeners.append(widget.set_flight_member) + return widget case _: raise UnhandledControlTypeError(prop.control) diff --git a/requirements.txt b/requirements.txt index a25e1085..1ce9154f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ pluggy==1.5.0 pre-commit==3.7.1 pydantic==2.7.4 pydantic-settings==2.3.3 -pydcs @ git+https://github.com/dcs-retribution/pydcs@ef9c72f1075546787b21da2bc25d228c32aca9ce +pydcs @ git+https://github.com/dcs-retribution/pydcs@4b63a60f410b5542c9654ebe4d74cfcb17e06eff pyinstaller==5.13.2 pyinstaller-hooks-contrib==2024.0 pyparsing==3.1.2