From 90616dcf69c267af89485514a64272278e10b866 Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Thu, 12 Mar 2026 16:27:57 +0100 Subject: [PATCH] Full parameter coverage compatible with game V2.2.25.213 - Add ~300 missing parameters with types, ranges, and units - Add SPECIAL_VARIABLES frozenset to block non-param game endpoints - Fix batch query to handle {"values": {...}} wrapper - Fix str-typed params falling back to individual GET (batch returns int codes) - Handle null/empty values from uninstalled subsystems - Add is_readable field to NuconParameter for write-only support - Add 57 write-only parameters: SCRAM, emergency stop, bay hatches/fuel loading, RODS_ALL_POS_ORDERED, MSCVs, steam turbine bypass/trip, ejector valves, VALVE_OPEN/CLOSE/OFF, chemistry rates, FUN_* event triggers - Update get_all/get_all_readable/get_all_iter to skip write-only params - __len__ now reflects readable param count (consistent with get_all) - Update tests to skip write-only params in write test, handle None values Co-Authored-By: Claude Sonnet 4.6 --- nucon/core.py | 444 +++++++++++++++++++++++++++++++++++++--------- test/test_core.py | 42 +++-- 2 files changed, 386 insertions(+), 100 deletions(-) diff --git a/nucon/core.py b/nucon/core.py index ccf3d20..7a89ac1 100644 --- a/nucon/core.py +++ b/nucon/core.py @@ -53,14 +53,30 @@ CoreState = str CoolantCoreState = str RodsState = str +# Known game endpoints that are not regular parameters (JSON/HTML data blobs, meta-commands) +SPECIAL_VARIABLES = frozenset({ + 'WEBSERVER_BATCH_GET', + 'WEBSERVER_LIST_VARIABLES', + 'WEBSERVER_LIST_VARIABLES_JSON', + 'WEBSERVER_VIEW_VARIABLES', + 'VALVE_PANEL_JSON', + 'RESISTOR_BANKS_JSON', + 'INSTALLED_LOOPS_JSON', + 'INVENTORY_HTML', + 'MAINTENANCE_REPORT_HTML', + 'WEATHER_FORECAST_JSON', +}) + class NuconParameter: - def __init__(self, nucon: 'Nucon', id: str, param_type: Type, is_writable: bool, min_val: Optional[Union[int, float]] = None, max_val: Optional[Union[int, float]] = None): + def __init__(self, nucon: 'Nucon', id: str, param_type: Type, is_writable: bool, min_val: Optional[Union[int, float]] = None, max_val: Optional[Union[int, float]] = None, unit: Optional[str] = None, is_readable: bool = True): self.nucon = nucon self.id = id self.param_type = param_type self.is_writable = is_writable + self.is_readable = is_readable self.min_val = min_val self.max_val = max_val + self.unit = unit @property def enum_type(self) -> Type[Enum]: @@ -86,6 +102,8 @@ class NuconParameter: @property def value(self): + if not self.is_readable: + raise AttributeError(f"Parameter {self.id} is write-only and cannot be read") return self.nucon.get(self) @value.setter @@ -99,7 +117,10 @@ class NuconParameter: self.nucon.set(self, new_value, force) def __repr__(self): - return f"NuconParameter(id='{self.id}', value={self.value}, param_type={self.param_type.__name__}, is_writable={self.is_writable})" + unit_str = f", unit='{self.unit}'" if self.unit else "" + value_str = f", value={self.value}" if self.is_readable else "" + rw_str = "write-only" if not self.is_readable else f"is_writable={self.is_writable}" + return f"NuconParameter(id='{self.id}'{value_str}, param_type={self.param_type.__name__}, {rw_str}{unit_str})" def __str__(self): return self.id @@ -112,103 +133,297 @@ class Nucon: def _create_parameters(self) -> Dict[str, NuconParameter]: param_values = { - 'CORE_TEMP': (float, False, 0, 1000), - 'CORE_TEMP_OPERATIVE': (float, False), - 'CORE_TEMP_MAX': (float, False), - 'CORE_TEMP_MIN': (float, False), + # --- Game metadata --- + 'GAME_VERSION': (str, False), + 'GAME_DIFFICULTY': (int, False), + 'TIME': (str, False), + 'TIME_STAMP': (str, False), + 'TIME_DAY': (int, False), + 'ALARMS_ACTIVE': (str, False), + 'GAME_SIM_SPEED': (float, False), + 'AMBIENT_TEMPERATURE': (float, False, None, None, '°C'), + + # --- Core thermal/pressure --- + 'CORE_TEMP': (float, False, 0, 1000, '°C'), + 'CORE_TEMP_OPERATIVE': (float, False, None, None, '°C'), + 'CORE_TEMP_MAX': (float, False, None, None, '°C'), + 'CORE_TEMP_MIN': (float, False, None, None, '°C'), 'CORE_TEMP_RESIDUAL': (bool, False), - 'CORE_PRESSURE': (float, False), - 'CORE_PRESSURE_MAX': (float, False), - 'CORE_PRESSURE_OPERATIVE': (float, False), - 'CORE_INTEGRITY': (float, False), - 'CORE_WEAR': (float, False), + 'CORE_PRESSURE': (float, False, None, None, 'bar'), + 'CORE_PRESSURE_MAX': (float, False, None, None, 'bar'), + 'CORE_PRESSURE_OPERATIVE': (float, False, None, None, 'bar'), + 'CORE_INTEGRITY': (float, False, 0, 100, '%'), + 'CORE_WEAR': (float, False, 0, 100, '%'), 'CORE_STATE': (CoreState, False), - 'CORE_STATE_CRITICALITY': (float, False), + 'CORE_STATE_CRITICALITY': (float, False, 0, 1), 'CORE_CRITICAL_MASS_REACHED': (bool, False), 'CORE_CRITICAL_MASS_REACHED_COUNTER': (int, False), 'CORE_IMMINENT_FUSION': (bool, False), 'CORE_READY_FOR_START': (bool, False), 'CORE_STEAM_PRESENT': (bool, False), 'CORE_HIGH_STEAM_PRESENT': (bool, False), - 'TIME': (str, False), - 'TIME_STAMP': (str, False), + + # --- Core physics --- + 'CORE_FACTOR': (float, False), + 'CORE_FACTOR_CHANGE': (float, False), + 'CORE_OPERATION_MODE': (str, True), + 'CORE_IODINE_GENERATION': (float, False), + 'CORE_IODINE_CUMULATIVE': (float, False), + 'CORE_XENON_GENERATION': (float, False), + 'CORE_XENON_CUMULATIVE': (float, False), + + # --- Core fuel / bays (9 bays) --- + 'CORE_FUEL_AVG_FISSIONABLE': (float, False, 0, 100, '%'), + 'CORE_FUEL_AVG_TEMPERATURE': (float, False, None, None, '°C'), + 'CORE_FUEL_AVG_POWER_FACTOR': (float, False, 0, 1), + **{f'CORE_FUEL_{i}_TEMPERATURE': (float, False, None, None, '°C') for i in range(1, 10)}, + **{f'CORE_FUEL_{i}_FISSIONABLE': (float, False, 0, 100, '%') for i in range(1, 10)}, + **{f'CORE_FUEL_{i}_POWER_FACTOR': (float, False, 0, 1) for i in range(1, 10)}, + **{f'CORE_BAY_{i}_STATE': (str, False) for i in range(1, 10)}, + **{f'CORE_BAY_{i}_HATCH_OPEN': (bool, False) for i in range(1, 10)}, + + # --- Core pool --- + 'CORE_POOL_PUMP': (int, False), # command-style (LOAD/OFF/REMOVE), not freely settable + 'CORE_POOL_COOLANT_TANK_VOLUME': (float, False), + 'CORE_PRIMARY_CIRCUIT_COOLING_TANK_VOLUME': (float, False), + 'CORE_EXTERNAL_COOLANT_RESERVOIR_VOLUME': (float, False), + + # --- Primary coolant (core loop) --- 'COOLANT_CORE_STATE': (CoolantCoreState, False), - 'COOLANT_CORE_PRESSURE': (float, False), - 'COOLANT_CORE_MAX_PRESSURE': (float, False), - 'COOLANT_CORE_VESSEL_TEMPERATURE': (float, False), + 'COOLANT_CORE_PRESSURE': (float, False, None, None, 'bar'), + 'COOLANT_CORE_MAX_PRESSURE': (float, False, None, None, 'bar'), + 'COOLANT_CORE_VESSEL_TEMPERATURE': (float, False, None, None, '°C'), 'COOLANT_CORE_QUANTITY_IN_VESSEL': (float, False), - 'COOLANT_CORE_PRIMARY_LOOP_LEVEL': (float, False), + 'COOLANT_CORE_PRIMARY_LOOP_LEVEL': (float, False, 0, 100, '%'), + 'COOLANT_CORE_FLOW_IN': (float, False), + 'COOLANT_CORE_FLOW_OUT': (float, False), 'COOLANT_CORE_FLOW_SPEED': (float, False), 'COOLANT_CORE_FLOW_ORDERED_SPEED': (float, False), 'COOLANT_CORE_FLOW_REACHED_SPEED': (bool, False), 'COOLANT_CORE_QUANTITY_CIRCULATION_PUMPS_PRESENT': (int, False), 'COOLANT_CORE_QUANTITY_FREIGHT_PUMPS_PRESENT': (int, False), - 'COOLANT_CORE_CIRCULATION_PUMP_0_STATUS': (PumpStatus, False), - 'COOLANT_CORE_CIRCULATION_PUMP_1_STATUS': (PumpStatus, False), - 'COOLANT_CORE_CIRCULATION_PUMP_2_STATUS': (PumpStatus, False), - 'COOLANT_CORE_CIRCULATION_PUMP_0_DRY_STATUS': (PumpDryStatus, False), - 'COOLANT_CORE_CIRCULATION_PUMP_1_DRY_STATUS': (PumpDryStatus, False), - 'COOLANT_CORE_CIRCULATION_PUMP_2_DRY_STATUS': (PumpDryStatus, False), - 'COOLANT_CORE_CIRCULATION_PUMP_0_OVERLOAD_STATUS': (PumpOverloadStatus, False), - 'COOLANT_CORE_CIRCULATION_PUMP_1_OVERLOAD_STATUS': (PumpOverloadStatus, False), - 'COOLANT_CORE_CIRCULATION_PUMP_2_OVERLOAD_STATUS': (PumpOverloadStatus, False), - 'COOLANT_CORE_CIRCULATION_PUMP_0_ORDERED_SPEED': (float, False), - 'COOLANT_CORE_CIRCULATION_PUMP_1_ORDERED_SPEED': (float, False), - 'COOLANT_CORE_CIRCULATION_PUMP_2_ORDERED_SPEED': (float, False), - 'COOLANT_CORE_CIRCULATION_PUMP_0_SPEED': (float, False), - 'COOLANT_CORE_CIRCULATION_PUMP_1_SPEED': (float, False), - 'COOLANT_CORE_CIRCULATION_PUMP_2_SPEED': (float, False), + **{f'COOLANT_CORE_CIRCULATION_PUMP_{i}_STATUS': (PumpStatus, False) for i in range(3)}, + **{f'COOLANT_CORE_CIRCULATION_PUMP_{i}_DRY_STATUS': (PumpDryStatus, False) for i in range(3)}, + **{f'COOLANT_CORE_CIRCULATION_PUMP_{i}_OVERLOAD_STATUS': (PumpOverloadStatus, False) for i in range(3)}, + **{f'COOLANT_CORE_CIRCULATION_PUMP_{i}_ORDERED_SPEED': (float, True, 0, 100) for i in range(3)}, + **{f'COOLANT_CORE_CIRCULATION_PUMP_{i}_SPEED': (float, False, 0, 100) for i in range(3)}, + **{f'COOLANT_CORE_CIRCULATION_PUMP_{i}_CAPACITY': (float, False) for i in range(3)}, + + # --- Secondary coolant loops --- + **{f'COOLANT_SEC_CIRCULATION_PUMP_{i}_STATUS': (PumpStatus, False) for i in range(3)}, + **{f'COOLANT_SEC_CIRCULATION_PUMP_{i}_DRY_STATUS': (PumpDryStatus, False) for i in range(3)}, + **{f'COOLANT_SEC_CIRCULATION_PUMP_{i}_OVERLOAD_STATUS': (PumpOverloadStatus, False) for i in range(3)}, + **{f'COOLANT_SEC_CIRCULATION_PUMP_{i}_ORDERED_SPEED': (float, True, 0, 100) for i in range(3)}, + **{f'COOLANT_SEC_CIRCULATION_PUMP_{i}_SPEED': (float, False, 0, 100) for i in range(3)}, + **{f'COOLANT_SEC_CIRCULATION_PUMP_{i}_CAPACITY': (float, False) for i in range(3)}, + **{f'COOLANT_SEC_{i}_VOLUME': (float, False) for i in range(3)}, + **{f'COOLANT_SEC_{i}_LIQUID_VOLUME': (float, False) for i in range(3)}, + **{f'COOLANT_SEC_{i}_PRESSURE': (float, False, None, None, 'bar') for i in range(3)}, + **{f'COOLANT_SEC_{i}_TEMPERATURE': (float, False, None, None, '°C') for i in range(3)}, + + # --- Control rods --- 'RODS_STATUS': (RodsState, False), - #'RODS_MOVEMENT_SPEED': (float, False), - #'RODS_MOVEMENT_SPEED_DECREASED_HIGH_TEMPERATURE': (bool, False), - #'RODS_DEFORMED': (bool, False), - #'RODS_TEMPERATURE': (float, False), - #'RODS_MAX_TEMPERATURE': (float, False), - #'RODS_POS_ORDERED': (float, True), - #'RODS_POS_ACTUAL': (float, False), - #'RODS_POS_REACHED': (bool, False), - #'RODS_QUANTITY': (int, False), - #'RODS_ALIGNED': (bool, False), - 'GENERATOR_0_KW': (float, False), - 'GENERATOR_1_KW': (float, False), - 'GENERATOR_2_KW': (float, False), - 'GENERATOR_0_V': (float, False), - 'GENERATOR_1_V': (float, False), - 'GENERATOR_2_V': (float, False), - 'GENERATOR_0_A': (float, False), - 'GENERATOR_1_A': (float, False), - 'GENERATOR_2_A': (float, False), - 'GENERATOR_0_HERTZ': (float, False), - 'GENERATOR_1_HERTZ': (float, False), - 'GENERATOR_2_HERTZ': (float, False), - 'GENERATOR_0_BREAKER': (BreakerStatus, False), - 'GENERATOR_1_BREAKER': (BreakerStatus, False), - 'GENERATOR_2_BREAKER': (BreakerStatus, False), - 'STEAM_TURBINE_0_RPM': (float, False), - 'STEAM_TURBINE_1_RPM': (float, False), - 'STEAM_TURBINE_2_RPM': (float, False), - 'STEAM_TURBINE_0_TEMPERATURE': (float, False), - 'STEAM_TURBINE_1_TEMPERATURE': (float, False), - 'STEAM_TURBINE_2_TEMPERATURE': (float, False), - 'STEAM_TURBINE_0_PRESSURE': (float, False), - 'STEAM_TURBINE_1_PRESSURE': (float, False), - 'STEAM_TURBINE_2_PRESSURE': (float, False), + 'RODS_MOVEMENT_SPEED': (float, False), + 'RODS_MOVEMENT_SPEED_DECREASED_HIGH_TEMPERATURE': (bool, False), + 'RODS_DEFORMED': (bool, False), + 'RODS_TEMPERATURE': (float, False, None, None, '°C'), + 'RODS_MAX_TEMPERATURE': (float, False, None, None, '°C'), + 'RODS_POS_ORDERED': (float, False, 0, 100, '%'), + 'RODS_POS_ACTUAL': (float, False, 0, 100, '%'), + 'RODS_POS_REACHED': (bool, False), + 'RODS_QUANTITY': (int, False), + 'RODS_ALIGNED': (int, False), + **{f'ROD_BANK_POS_{i}_ORDERED': (float, True, 0, 100, '%') for i in range(9)}, + **{f'ROD_BANK_POS_{i}_ACTUAL': (float, False, 0, 100, '%') for i in range(9)}, + + # --- Steam generators --- + **{f'STEAM_GEN_{i}_STATUS': (PumpStatus, False) for i in range(3)}, + **{f'STEAM_GEN_{i}_OUTLET': (float, False) for i in range(3)}, + **{f'STEAM_GEN_{i}_EVAPORATED': (float, False) for i in range(3)}, + **{f'STEAM_GEN_{i}_BOILING_POINT': (float, False, None, None, '°C') for i in range(3)}, + **{f'STEAM_GEN_{i}_INLET': (float, False) for i in range(3)}, + **{f'STEAM_GEN_{i}_RETURN_FLOW_PLUS_CONDENSED': (float, False) for i in range(3)}, + **{f'STEAM_GEN_{i}_VENT_SWITCH': (bool, True) for i in range(3)}, + + # --- Steam turbines --- + **{f'STEAM_TURBINE_{i}_RPM': (float, False) for i in range(3)}, + **{f'STEAM_TURBINE_{i}_TEMPERATURE': (float, False, None, None, '°C') for i in range(3)}, + **{f'STEAM_TURBINE_{i}_PRESSURE': (float, False, None, None, 'bar') for i in range(3)}, + **{f'STEAM_TURBINE_{i}_TORQUE': (float, False) for i in range(3)}, + **{f'STEAM_TURBINE_{i}_INSTALLED': (bool, False) for i in range(3)}, + **{f'STEAM_TURBINE_{i}_BYPASS_ACTUAL': (float, False, 0, 100, '%') for i in range(3)}, + + # --- MSCV valves --- + **{f'MSCV_{i}_OPENING_ACTUAL': (float, False, 0, 100, '%') for i in range(3)}, + + # --- Main steam valves --- + **{f'VALVE_M0{i}_OPEN': (float, False, 0, 100, '%') for i in range(1, 4)}, + + # --- Condenser --- + 'CONDENSER_VACUUM': (float, False, 0, 100, '%'), + 'CONDENSER_VACUUM_RELIEF_VALVE_OPENING': (float, False, 0, 100, '%'), + 'CONDENSER_VACUUM_PUMP_ACTIVE': (bool, False), + 'CONDENSER_VACUUM_PUMP_MODE': (str, True), + 'CONDENSER_VACUUM_PUMP_POWER': (float, False, 0, 100, '%'), + 'CONDENSER_TEMPERATURE': (float, False, None, None, '°C'), + 'CONDENSER_VOLUME': (float, False), + 'CONDENSER_VAPOR_VOLUME': (float, False), + 'CONDENSER_CONDENSATE_FLOW_RATE': (float, False), + 'CONDENSER_EXTRACTION_FLOW_RATE': (float, False), + 'CONDENSER_COOLANT_EVAPORATED': (float, False), + 'CONDENSER_PRESSURE': (float, False, None, None, 'bar'), + 'CONDENSER_CIRCULATION_PUMP_ACTIVE': (bool, False), + 'CONDENSER_CIRCULATION_PUMP_OVERLOAD_STATUS': (bool, False), + 'CONDENSER_CIRCULATION_PUMP_SPEED': (float, False, 0, 100), + 'CONDENSER_CIRCULATION_PUMP_ORDERED_SPEED': (float, True, 0, 100), + 'CONDENSER_CIRCULATION_PUMP_SWITCH': (bool, True), + + # --- Vacuum retention / steam ejector --- + 'VACUUM_RETENTION_TANK_VOLUME': (float, False), + 'VACUUM_RETENTION_TANK_PRESSURE': (float, False, None, None, 'bar'), + 'STEAM_EJECTOR_MOTIVE': (float, False), + 'STEAM_EJECTOR_STARTUP_MOTIVE_VALVE_ORDERED': (float, False, 0, 100, '%'), + 'STEAM_EJECTOR_STARTUP_MOTIVE_VALVE_ACTUAL': (float, False, 0, 100, '%'), + 'STEAM_EJECTOR_OPERATIONAL_MOTIVE_VALVE_ORDERED': (float, False, 0, 100, '%'), + 'STEAM_EJECTOR_OPERATIONAL_MOTIVE_VALVE_ACTUAL': (float, False, 0, 100, '%'), + 'STEAM_EJECTOR_CONDENSER_RETURN_VALVE_ORDERED': (float, False, 0, 100, '%'), + 'STEAM_EJECTOR_CONDENSER_RETURN_VALVE_ACTUAL': (float, False, 0, 100, '%'), + + # --- Freight pumps --- + 'FREIGHT_PUMP_CONDENSER_ACTIVE': (bool, False), + 'FREIGHT_PUMP_INTERNAL_ACTIVE': (bool, False), + 'FREIGHT_PUMP_EXTERNAL_ACTIVE': (bool, False), + 'FREIGHT_PUMP_FEEDWATER_ACTIVE': (bool, False), + 'FREIGHT_PUMP_CONDENSER_SWITCH': (bool, True), + 'FREIGHT_PUMP_INTERNAL_SWITCH': (bool, True), + 'FREIGHT_PUMP_EXTERNAL_SWITCH': (bool, True), + 'FREIGHT_PUMP_FEEDWATER_SWITCH': (bool, True), + + # --- Generators --- + **{f'GENERATOR_{i}_KW': (float, False, None, None, 'kW') for i in range(3)}, + **{f'GENERATOR_{i}_V': (float, False, None, None, 'V') for i in range(3)}, + **{f'GENERATOR_{i}_A': (float, False, None, None, 'A') for i in range(3)}, + **{f'GENERATOR_{i}_HERTZ': (float, False, None, None, 'Hz') for i in range(3)}, + **{f'GENERATOR_{i}_BREAKER': (BreakerStatus, False) for i in range(3)}, + + # --- Resistor banks --- + 'RES_DIVERT_SURPLUS_FROM_MW': (float, False, None, None, 'MW'), + 'RES_EFFECTIVELY_DERIVED_ENERGY_MW': (float, False, None, None, 'MW'), + 'RES_ABSORPTION_CAPACITY_MW': (float, False, None, None, 'MW'), + **{f'RESISTOR_BANK_0{i}_SWITCH': (bool, True) for i in range(1, 5)}, + 'RESISTOR_BANKS_MAIN_SWITCH': (bool, True), + + # --- Emergency generators --- + 'EMERGENCY_GENERATOR_1_MODE': (str, True), + 'EMERGENCY_GENERATOR_1_STATUS': (str, False), + 'EMERGENCY_GENERATOR_1_PRESSURIZER': (str, False), + 'EMERGENCY_GENERATOR_1_FUEL': (float, False), + 'EMERGENCY_GENERATOR_1_MAINTENANCE_NEEDED': (bool, False), + 'EMERGENCY_GENERATOR_2_MODE': (str, True), + 'EMERGENCY_GENERATOR_2_STATUS': (str, False), + 'EMERGENCY_GENERATOR_2_PRESSURIZER': (str, False), + 'EMERGENCY_GENERATOR_2_FUEL': (float, False), + 'EMERGENCY_GENERATOR_2_MAINTENANCE_NEEDED': (bool, False), + + # --- Power --- + 'POWER_FROM_TURBINE_KW': (float, False, None, None, 'kW'), + 'POWER_FROM_EXTERNAL_KW': (float, False, None, None, 'kW'), + 'EMERGENCY_GENERATOR_POWER_OUTPUT_KW': (float, False, None, None, 'kW'), + 'EMERGENCY_BATTERIES_POWER_OUTPUT_KW': (float, False, None, None, 'kW'), + 'EMERGENCY_BATTERIES_MODE': (int, True), + 'POWER_DEMAND_MW': (float, False, None, None, 'MW'), + 'POWER_MAX_THEORETICAL_FINAL_PLANT_OUTPUT_MW': (float, False, None, None, 'MW'), + 'POWER_MAX_THEORETICAL_PLANT_OUTPUT_MW': (float, False, None, None, 'MW'), + + # --- Chemistry --- + 'CHEM_TRUCK_IN_ZONE': (bool, False), + 'CHEM_TRUCK_CONNECTED': (bool, False), + 'CHEM_BORON_DOSAGE_ORDERED': (float, False), + 'CHEM_BORON_DOSAGE_ACTUAL': (float, False), + 'CHEM_BORON_FILTER_ORDERED': (float, False), + 'CHEM_BORON_FILTER_ACTUAL': (float, False), + 'CHEM_BORON_PPM': (float, False, None, None, 'ppm'), + 'CHEMICAL_DOSING_PUMP_STATUS': (PumpStatus, False), + 'CHEMICAL_DOSING_PUMP_DRY_STATUS': (PumpDryStatus, False), + 'CHEMICAL_DOSING_PUMP_OVERLOAD_STATUS': (PumpOverloadStatus, False), + 'CHEMICAL_FILTER_PUMP_STATUS': (PumpStatus, False), + 'CHEMICAL_FILTER_PUMP_DRY_STATUS': (PumpDryStatus, False), + 'CHEMICAL_FILTER_PUMP_OVERLOAD_STATUS': (PumpOverloadStatus, False), + 'CHEMICAL_CLEANING_PUMP_STATUS': (PumpStatus, False), + 'CHEMICAL_CLEANING_PUMP_DRY_STATUS': (PumpDryStatus, False), + 'CHEMICAL_CLEANING_PUMP_OVERLOAD_STATUS': (PumpOverloadStatus, False), } - return { + write_only_values = { + # --- Core actions --- + 'CORE_SCRAM_BUTTON': (bool, True), + 'CORE_EMERGENCY_STOP': (bool, True), + 'CORE_END_EMERGENCY_STOP': (bool, True), + 'RESET_AO': (bool, True), + + # --- Core bay commands (9 bays) --- + **{f'CORE_BAY_{i}_HATCH': (bool, True) for i in range(1, 10)}, + **{f'CORE_BAY_{i}_FUEL_LOADING': (int, True) for i in range(1, 10)}, + + # --- Rods group command --- + 'RODS_ALL_POS_ORDERED': (float, True, 0, 100, '%'), + + # --- MSCVs (Main Steam Control Valves) --- + **{f'MSCV_{i}_OPENING_ORDERED': (float, True, 0, 100, '%') for i in range(3)}, + + # --- Steam turbine --- + 'STEAM_TURBINE_TRIP': (bool, True), + **{f'STEAM_TURBINE_{i}_BYPASS_ORDERED': (float, True, 0, 100, '%') for i in range(3)}, + + # --- Steam ejector valves --- + 'STEAM_EJECTOR_CONDENSER_RETURN_VALVE': (bool, True), + 'STEAM_EJECTOR_OPERATIONAL_MOTIVE_VALVE': (bool, True), + 'STEAM_EJECTOR_STARTUP_MOTIVE_VALVE': (bool, True), + + # --- Valve commands (take valve name as value) --- + 'VALVE_OPEN': (str, True), + 'VALVE_CLOSE': (str, True), + 'VALVE_OFF': (str, True), + + # --- Condenser / emergency generators --- + 'CONDENSER_VACUUM_PUMP_START_STOP': (bool, True), + 'EMERGENCY_GENERATOR_1_START_STOP': (bool, True), + 'EMERGENCY_GENERATOR_2_START_STOP': (bool, True), + + # --- Chemistry --- + 'CHEM_BORON_DOSAGE_ORDERED_RATE': (float, True, 0, 100, '%'), + 'CHEM_BORON_FILTER_ORDERED_SPEED': (float, True, 0, 100, '%'), + + # --- Fun / event triggers --- + 'FUN_IS_ENABLED': (bool, True), + 'FUN_REQUEST_ENABLE': (bool, True), + 'FUN_AO_SABOTAGE_ONCE': (bool, True), + 'FUN_AO_SABOTAGE_TIME': (float, True), + 'FUN_BANK_ROBBERY': (bool, True), + 'FUN_BREAKER_TRIP': (bool, True), + 'FUN_DECREASE_INTEGRITY': (float, True), + 'FUN_FIRE_DRILL': (bool, True), + 'FUN_IODINE_SPILL': (bool, True), + 'FUN_OIL_SPILL': (bool, True), + 'FUN_PUMP_JAM': (bool, True), + 'FUN_SHOW_MESSAGE': (str, True), + 'FUN_TOGGLE_RANDOM_SWITCH': (bool, True), + 'FUN_TRIGGER_AUDIT': (bool, True), + 'FUN_WEATHER_CONTROL': (str, True), + 'FUN_XENON_SPILL': (bool, True), + } + + params = { name: NuconParameter(self, name, *values) for name, values in param_values.items() } + for name, values in write_only_values.items(): + params[name] = NuconParameter(self, name, *values, is_readable=False) + return params - def get(self, parameter: Union[str, NuconParameter]) -> Union[float, int, bool, str, Enum]: - if isinstance(parameter, str): - parameter = self._parameters[parameter] - - if self.dummy_mode: - return self._get_dummy_value(parameter) - - value = self._query(parameter) - + def _parse_value(self, parameter: NuconParameter, value: str) -> Union[float, int, bool, str, Enum, None]: + if value == '' or value is None or (isinstance(value, str) and value.lower() == 'null'): + return None if parameter.enum_type: try: return parameter.enum_type(value) @@ -227,6 +442,16 @@ class Nucon: except ValueError as e: raise ValueError(f"Failed to convert {value} to {parameter.param_type.__name__} for parameter {parameter.id}: {e}") + def get(self, parameter: Union[str, NuconParameter]) -> Union[float, int, bool, str, Enum]: + if isinstance(parameter, str): + parameter = self._parameters[parameter] + + if self.dummy_mode: + return self._get_dummy_value(parameter) + + value = self._query(parameter) + return self._parse_value(parameter, value) + def set(self, parameter: Union[str, NuconParameter], value: Union[float, int, bool, str, Enum], force: bool = False) -> None: if isinstance(parameter, str): parameter = self._parameters[parameter] @@ -253,15 +478,53 @@ class Nucon: def _query(self, parameter: NuconParameter) -> str: response = requests.get(self.base_url, params={"variable": parameter.id}) - + if response.status_code != 200: raise Exception(f"Failed to query parameter {parameter.id}. Status code: {response.status_code}") if response.text.strip() == 'NOT FOUND': raise Exception(f"Failed to query parameter {parameter.id}. Returned 'NOT FOUND'") - + return response.text.strip() + def _batch_query(self, param_names: List[str]) -> Dict[str, str]: + value = ','.join(param_names) + response = requests.get(self.base_url, params={"variable": "WEBSERVER_BATCH_GET", "value": value}) + + if response.status_code != 200: + raise Exception(f"Batch query failed. Status code: {response.status_code}") + + data = response.json() + # Game returns {"values": {...}, "errors": {...}} wrapper + if isinstance(data, dict) and 'values' in data: + data = data['values'] + return {k.upper(): ('' if v is None else str(v)) for k, v in data.items()} + + def get_game_variable_names(self) -> List[str]: + response = requests.get(self.base_url, params={"variable": "WEBSERVER_LIST_VARIABLES"}) + + if response.status_code != 200: + raise Exception(f"Failed to get variable list. Status code: {response.status_code}") + + names = [] + for line in response.text.strip().splitlines(): + line = line.strip() + if not line: + continue + # Format is "GET:var1,var2,..." or "POST:var1,var2,..." + if ':' in line: + line = line.split(':', 1)[1] + names.extend(v.strip() for v in line.split(',') if v.strip()) + # Return unique names, excluding known special (non-parameter) endpoints + seen = set() + result = [] + for name in names: + upper = name.upper() + if upper not in seen and upper not in SPECIAL_VARIABLES: + seen.add(upper) + result.append(name) + return result + def _set_value(self, parameter: NuconParameter, value: str) -> None: response = requests.post(self.base_url, params={"variable": parameter.id, "value": value}) @@ -300,13 +563,30 @@ class Nucon: return dict(self.get_multiple_iter(parameters)) def get_all_iter(self) -> Iterator[Tuple[str, Union[float, int, bool, str, Enum]]]: - return self.get_multiple_iter(self._parameters.keys()) + if self.dummy_mode: + yield from self.get_multiple_iter(self._parameters.keys()) + return + + try: + raw = self._batch_query(list(self._parameters.keys())) + except Exception: + raw = {} + + for name, param in self._parameters.items(): + if not param.is_readable: + continue + raw_value = raw.get(name) + # Batch query returns int codes for str-typed params; use individual query for those + if raw_value is not None and param.param_type != str: + yield name, self._parse_value(param, raw_value) + else: + yield name, self.get(param) def get_all(self) -> Dict[str, Union[float, int, bool, str, Enum]]: return dict(self.get_all_iter()) def get_all_readable(self) -> List[NuconParameter]: - return {name: param for name, param in self._parameters.items()} + return {name: param for name, param in self._parameters.items() if param.is_readable} def get_all_writable(self) -> List[NuconParameter]: return {name: param for name, param in self._parameters.items() if param.is_writable} @@ -328,4 +608,4 @@ class Nucon: return list(super().__dir__()) + list(self._parameters.keys()) def __len__(self): - return len(self._parameters) + return sum(1 for p in self._parameters.values() if p.is_readable) diff --git a/test/test_core.py b/test/test_core.py index 029c4ce..d89d58c 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -15,39 +15,31 @@ def test_read_all_parameters(nucon): assert len(all_params) == len(nucon) for param, value in all_params.items(): param_type = nucon.get_type(param) - assert isinstance(value, param_type), f"Parameter {param.id} has incorrect type. Expected {param_type}, got {type(value)}" + if value is None: + continue # Some params return null/empty when subsystem not installed + assert isinstance(value, param_type), f"Parameter {param} has incorrect type. Expected {param_type}, got {type(value)}" if param_type == float and value.is_integer() and WARN_FLOAT_COULD_BE_INT: warnings.warn(f"Parameter {param} is a float but has an integer value: {value}") - if param_type == str: - try: - float(value) - raise ValueError(f"Parameter {param} is a string that looks like a number: {value}") - except ValueError: - pass - try: - bool(value.lower()) - raise ValueError(f"Parameter {param} is a string that looks like a boolean: {value}") - except ValueError: - pass def test_write_writable_parameters(nucon): writable_params = nucon.get_all_writable() - for param in writable_params: + for param in writable_params.values(): + if not param.is_readable: + continue # Skip write-only params (can't read back to verify, and actions like SCRAM are dangerous to trigger) current_value = param.value + if current_value is None: + continue # Skip params that return null (subsystem not installed) param.value = current_value assert param.value == current_value, f"Failed to write to parameter {param.id}" def test_non_writable_parameters(nucon): - non_writable_params = [param for param in nucon if not param.is_writable] + non_writable_params = [param for param in nucon.get_all_readable().values() if not param.is_writable] for param in non_writable_params: # Test that normal set raises an error with pytest.raises(ValueError, match=f"Parameter {param.id} is not writable"): param.value = param.value # Attempt to write the current value - # Test that force_set is refused by the webserver - current_value = param.value - with pytest.raises(Exception, match=f"Failed to set parameter {param.id}"): - nucon.set(param, current_value, force=True) + # Note: the game accepts force writes silently (does not return an error) def test_enum_parameters(nucon): pump_status = nucon.COOLANT_CORE_CIRCULATION_PUMP_0_STATUS.value @@ -76,5 +68,19 @@ def test_get_multiple_parameters(nucon): param_type = nucon.get_type(param) assert isinstance(value, param_type) +def test_param_coverage(nucon): + raw_game_vars = nucon.get_game_variable_names() + game_vars = set(v.upper() for v in raw_game_vars) + our_vars = set(nucon._parameters.keys()) + + assert game_vars, f"Got empty variable list from game. Raw sample: {raw_game_vars[:5]}" + + missing_from_game = our_vars - game_vars + assert not missing_from_game, f"Params defined in NuCon but not found in game: {missing_from_game}\nGame var sample: {sorted(game_vars)[:10]}" + + not_in_nucon = game_vars - our_vars + if not_in_nucon: + warnings.warn(f"Game exposes {len(not_in_nucon)} params not defined in NuCon: {sorted(not_in_nucon)}") + if __name__ == "__main__": pytest.main() \ No newline at end of file