diff --git a/src/pandablocks_ioc/_pvi.py b/src/pandablocks_ioc/_pvi.py index b19f4e8d..48785a3d 100644 --- a/src/pandablocks_ioc/_pvi.py +++ b/src/pandablocks_ioc/_pvi.py @@ -89,6 +89,7 @@ class PviGroup(Enum): CAPTURE = "Capture" HDF = "HDF" TABLE = "Table" # TODO: May not need this anymore + VERSIONS = "Versions" @dataclass diff --git a/src/pandablocks_ioc/ioc.py b/src/pandablocks_ioc/ioc.py index 35fae5c0..452ceb80 100644 --- a/src/pandablocks_ioc/ioc.py +++ b/src/pandablocks_ioc/ioc.py @@ -14,6 +14,7 @@ Arm, ChangeGroup, Disarm, + Get, GetBlockInfo, GetChanges, GetFieldInfo, @@ -66,6 +67,7 @@ trim_description, trim_string_value, ) +from ._version import __version__ # TODO: Try turning python.analysis.typeCheckingMode on, as it does highlight a couple # of possible errors @@ -175,6 +177,51 @@ def create_softioc( asyncio.run_coroutine_threadsafe(client.close(), dispatcher.loop).result() +def get_panda_versions(idn_repsonse: str) -> dict[EpicsName, str]: + """Function that parses version info from the PandA's response to the IDN command + + See: https://pandablocks-server.readthedocs.io/en/latest/commands.html#system-commands + + Args: + idn_response (str): Response from PandA to Get(*IDN) command + + Returns: + dict[EpicsName, str]: Dictionary mapping firmware record name to version + """ + + # Currently, IDN reports sw, fpga, and rootfs versions + firmware_versions = {"PandA SW": "Unknown", "FPGA": "Unknown", "rootfs": "Unknown"} + + # If the *IDN response contains too many keys, break and leave versions as "Unknown" + # Since spaces are used to deliminate versions and can also be in the keys and + # values, if an additional key is present that we don't explicitly handle, + # our approach of using regex matching will not work. + if sum(name in idn_repsonse for name in firmware_versions) < idn_repsonse.count( + ":" + ): + logging.error( + f"Recieved unexpected version numbers in version string {idn_repsonse}!" + ) + else: + for firmware_name in firmware_versions: + pattern = re.compile( + rf'{re.escape(firmware_name)}:\s*([^:]+?)(?=\s*\b(?: \ + {"|".join(map(re.escape, firmware_versions))}):|$)' + ) + if match := pattern.search(idn_repsonse): + firmware_versions[firmware_name] = match.group(1).strip() + logging.info( + f"{firmware_name} Version: {firmware_versions[firmware_name]}" + ) + else: + logging.warning(f"Failed to get {firmware_name} version information!") + + return { + EpicsName(firmware_name.upper().replace(" ", "_")): version + for firmware_name, version in firmware_versions.items() + } + + async def introspect_panda( client: AsyncioClient, ) -> tuple[dict[str, _BlockAndFieldInfo], dict[EpicsName, RecordValue]]: @@ -1824,6 +1871,40 @@ def create_block_records( return record_dict + def create_version_records(self, firmware_versions: dict[EpicsName, str]): + """Creates handful of records for tracking versions of IOC/Firmware via EPICS + + Args: + firmware_versions (dict[str, str]): Dictionary mapping firmwares to versions + """ + + system_block_prefix = "SYSTEM" + + ioc_version_record_name = EpicsName(system_block_prefix + ":IOC_VERSION") + ioc_version_record = builder.stringIn( + ioc_version_record_name, DESC="IOC Version", initial_value=__version__ + ) + add_automatic_pvi_info( + PviGroup.VERSIONS, + ioc_version_record, + ioc_version_record_name, + builder.stringIn, + ) + + for firmware_name, version in firmware_versions.items(): + firmware_record_name = EpicsName( + system_block_prefix + f":{firmware_name}_VERSION" + ) + firmware_ver_record = builder.stringIn( + firmware_record_name, DESC=firmware_name, initial_value=version + ) + add_automatic_pvi_info( + PviGroup.VERSIONS, + firmware_ver_record, + firmware_record_name, + builder.stringIn, + ) + def initialise(self, dispatcher: asyncio_dispatcher.AsyncioDispatcher) -> None: """Perform any final initialisation code to create the records. No new records may be created after this method is called. @@ -1851,6 +1932,10 @@ async def create_records( """Query the PandA and create the relevant records based on the information returned""" + # Get version information from PandA using IDN command + idn_response = await client.send(Get("*IDN")) + fw_vers_dict = get_panda_versions(idn_response) + (panda_dict, all_values_dict) = await introspect_panda(client) # Dictionary containing every record of every type @@ -1858,8 +1943,10 @@ async def create_records( record_factory = IocRecordFactory(client, record_prefix, all_values_dict) - # For each field in each block, create block_num records of each field + # Add records for version of IOC, FPGA, and software to SYSTEM block + record_factory.create_version_records(fw_vers_dict) + # For each field in each block, create block_num records of each field for block, panda_info in panda_dict.items(): block_info = panda_info.block_info values = panda_info.values diff --git a/tests/fixtures/mocked_panda.py b/tests/fixtures/mocked_panda.py index e7165d76..533ed76b 100644 --- a/tests/fixtures/mocked_panda.py +++ b/tests/fixtures/mocked_panda.py @@ -22,6 +22,7 @@ ChangeGroup, Command, Disarm, + Get, GetBlockInfo, GetChanges, GetFieldInfo, @@ -430,6 +431,10 @@ def multiple_seq_responses(table_field_info, table_data_1, table_data_2): GetChanges is polled at 10Hz if a different command isn't made. """ return { + command_to_key(Get(field="*IDN")): repeat( + "PandA SW: 3.0-11-g6422090 FPGA: 3.0.0C4 86e5f0a2 07d202f8 \ + rootfs: PandA 3.1a1-1-g22fdd94" + ), command_to_key( Put( field="SEQ1.TABLE", @@ -564,6 +569,10 @@ def no_numbered_suffix_to_metadata_responses(table_field_info, table_data_1): doesn't have a suffixed number. """ return { + command_to_key(Get(field="*IDN")): repeat( + "PandA SW: 3.0-11-g6422090 FPGA: 3.0.0C4 86e5f0a2 07d202f8 \ + rootfs: PandA 3.1a1-1-g22fdd94" + ), command_to_key( Put( field="SEQ.TABLE", @@ -639,6 +648,10 @@ def faulty_multiple_pcap_responses(): ), } return { + command_to_key(Get(field="*IDN")): repeat( + "PandA SW: 3.0-11-g6422090 FPGA: 3.0.0C4 86e5f0a2 07d202f8 \ + rootfs: PandA 3.1a1-1-g22fdd94" + ), command_to_key(GetFieldInfo(block="PCAP1", extended_metadata=True)): repeat( pcap_info ), @@ -681,6 +694,10 @@ def standard_responses_no_panda_update(table_field_info, table_data_1): Used to test if the softioc can be started. """ return { + command_to_key(Get(field="*IDN")): repeat( + "PandA SW: 3.0-11-g6422090 FPGA: 3.0.0C4 86e5f0a2 07d202f8 \ + rootfs: PandA 3.1a1-1-g22fdd94" + ), command_to_key(GetFieldInfo(block="PCAP", extended_metadata=True)): repeat( { "TRIG_EDGE": EnumFieldInfo( @@ -737,6 +754,10 @@ def standard_responses(table_field_info, table_data_1, table_data_2): GetChanges is polled at 10Hz if a different command isn't made. """ return { + command_to_key(Get(field="*IDN")): repeat( + "PandA SW: 3.0-11-g6422090 FPGA: 3.0.0C4 86e5f0a2 07d202f8 \ + rootfs: PandA 3.1a1-1-g22fdd94" + ), command_to_key(GetFieldInfo(block="PCAP", extended_metadata=True)): repeat( { "TRIG_EDGE": EnumFieldInfo( diff --git a/tests/test-bobfiles/SYSTEM.bob b/tests/test-bobfiles/SYSTEM.bob new file mode 100644 index 00000000..f6f58514 --- /dev/null +++ b/tests/test-bobfiles/SYSTEM.bob @@ -0,0 +1,128 @@ + + SYSTEM + 0 + 0 + 506 + 166 + 4 + 4 + + Title + TITLE + SYSTEM + 0 + 0 + 506 + 25 + + + + + + + + + true + 1 + + + VERSIONS + 5 + 30 + 496 + 131 + true + + Label + Ioc Version + 0 + 0 + 250 + 20 + $(text) + + + TextUpdate + TEST_PREFIX:SYSTEM:IOC_VERSION + 255 + 0 + 205 + 20 + + + + + 1 + 6 + + + Label + Panda Sw Version + 0 + 25 + 250 + 20 + $(text) + + + TextUpdate + TEST_PREFIX:SYSTEM:PANDA_SW_VERSION + 255 + 25 + 205 + 20 + + + + + 1 + 6 + + + Label + Fpga Version + 0 + 50 + 250 + 20 + $(text) + + + TextUpdate + TEST_PREFIX:SYSTEM:FPGA_VERSION + 255 + 50 + 205 + 20 + + + + + 1 + 6 + + + Label + Rootfs Version + 0 + 75 + 250 + 20 + $(text) + + + TextUpdate + TEST_PREFIX:SYSTEM:ROOTFS_VERSION + 255 + 75 + 205 + 20 + + + + + 1 + 6 + + + diff --git a/tests/test-bobfiles/index.bob b/tests/test-bobfiles/index.bob index 8172b259..c7a1aab3 100644 --- a/tests/test-bobfiles/index.bob +++ b/tests/test-bobfiles/index.bob @@ -3,7 +3,7 @@ 0 0 488 - 130 + 155 4 4 @@ -27,7 +27,7 @@ Label - PCAP + SYSTEM 23 30 250 @@ -38,7 +38,7 @@ OpenDisplay - PCAP.bob + SYSTEM.bob tab Open Display @@ -52,7 +52,7 @@ Label - DATA + PCAP 23 55 250 @@ -63,7 +63,7 @@ OpenDisplay - DATA.bob + PCAP.bob tab Open Display @@ -77,7 +77,7 @@ Label - SEQ + DATA 23 80 250 @@ -88,7 +88,7 @@ OpenDisplay - SEQ.bob + DATA.bob tab Open Display @@ -102,7 +102,7 @@ Label - PULSE + SEQ 23 105 250 @@ -113,7 +113,7 @@ OpenDisplay - PULSE.bob + SEQ.bob tab Open Display @@ -125,4 +125,29 @@ 20 $(actions) + + Label + PULSE + 23 + 130 + 250 + 20 + $(text) + + + OpenDisplay + + + PULSE.bob + tab + Open Display + + + SubScreen + 278 + 130 + 205 + 20 + $(actions) + diff --git a/tests/test_connection.py b/tests/test_connection.py index c5598080..67f643be 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -13,6 +13,7 @@ Arm, ChangeGroup, Disarm, + Get, GetBlockInfo, GetChanges, GetFieldInfo, @@ -47,6 +48,10 @@ async def test_no_panda_found_connection_error(): def panda_disconnect_responses(table_field_info, table_data_1, table_data_2): # The responses return nothing, as the panda disconnects after introspection return { + command_to_key(Get(field="*IDN")): repeat( + "PandA SW: 3.0-11-g6422090 FPGA: 3.0.0C4 86e5f0a2 07d202f8 \ + rootfs: PandA 3.1a1-1-g22fdd94" + ), command_to_key(GetFieldInfo(block="PCAP", extended_metadata=True)): repeat( { "TRIG_EDGE": EnumFieldInfo( diff --git a/tests/test_ioc.py b/tests/test_ioc.py index 6508c17f..faa9e7b2 100644 --- a/tests/test_ioc.py +++ b/tests/test_ioc.py @@ -39,6 +39,7 @@ StringRecordLabelValidator, _RecordUpdater, _TimeRecordUpdater, + get_panda_versions, update, ) @@ -840,3 +841,70 @@ class MockConnectionStatus: # unreliable number of calls to the set method. record_info.record.set.assert_any_call(True) record_info.record.set.assert_any_call(0) + + +@pytest.mark.parametrize( + "sample_idn_response, expected_output, expected_log_messages", + [ + ( + "PandA SW: 3.0-11-g6422090 FPGA: 3.0.0C4 86e5f0a2 " + "07d202f8 rootfs: PandA 3.1a1-1-g22fdd94", + { + EpicsName("PANDA_SW"): "3.0-11-g6422090", + EpicsName("FPGA"): "3.0.0C4 86e5f0a2 07d202f8", + EpicsName("ROOTFS"): "PandA 3.1a1-1-g22fdd94", + }, + [], + ), + ( + "PandA SW: 3.0-11-g6422090 FPGA: 3.0.0C4 86e5f0a2 07d202f8", + { + EpicsName("PANDA_SW"): "3.0-11-g6422090", + EpicsName("FPGA"): "3.0.0C4 86e5f0a2 07d202f8", + EpicsName("ROOTFS"): "Unknown", + }, + ["Failed to get rootfs version information!"], + ), + ( + "PandA SW: 3.0-11-g6422090 rootfs: PandA 3.1a1-1-g22fdd94", + { + EpicsName("PANDA_SW"): "3.0-11-g6422090", + EpicsName("FPGA"): "Unknown", + EpicsName("ROOTFS"): "PandA 3.1a1-1-g22fdd94", + }, + ["Failed to get FPGA version information!"], + ), + ( + "", + { + EpicsName("PANDA_SW"): "Unknown", + EpicsName("FPGA"): "Unknown", + EpicsName("ROOTFS"): "Unknown", + }, + [ + "Failed to get PandA SW version information!", + "Failed to get FPGA version information!", + "Failed to get rootfs version information!", + ], + ), + ( + "FPGA: 3.0.0C4 86e5f0a2 07d202f8 " + "Hello World: 12345 rootfs: PandA 3.1a1-1-g22fdd94", + { + EpicsName("PANDA_SW"): "Unknown", + EpicsName("FPGA"): "Unknown", + EpicsName("ROOTFS"): "Unknown", + }, + [ + "Recieved unexpected version numbers", + ], + ), + ], +) +def test_get_version_information( + sample_idn_response, expected_output, expected_log_messages, caplog +): + parsed_firmware_versions = get_panda_versions(sample_idn_response) + assert parsed_firmware_versions == expected_output + for log_message in expected_log_messages: + assert log_message in caplog.text