diff --git a/README.md b/README.md index 9039b1b1..7f306837 100644 --- a/README.md +++ b/README.md @@ -140,10 +140,10 @@ python driver.py -t #### A few optional settings for the driver.py file Options that may be added to the driver.py test run. Use these at your own discretion. -`--conftest-seed=###` - set the random values seed for this run -`--randomly-seed=###` - set the random order seed for this run -`--verbose` or `-v` - set verbosity level, also -vv, -vvv, etc. -`-k KEYWORD` - only run tests that match the KEYWORD (see `pytest --help`) +`--conftest-seed=###` - set the random values seed for this run +`--randomly-seed=###` - set the random order seed for this run +`--verbose` or `-v` - set verbosity level, also -vv, -vvv, etc. +`-k KEYWORD` - only run tests that match the KEYWORD (see `pytest --help`) NOTE: Running tests will output results using provided seeds, but each seed is random when not set directly. Example start of test output: @@ -164,35 +164,38 @@ python -m coverage run --branch --source=onair,plugins -m pytest ./test/ #### Command breakdown: -`python -m` - invokes the python runtime on the library following the -m -`coverage run` - runs coverage data collection during testing, wrapping itself on the test runner used -`--branch` - includes code branching information in the coverage report -`--source=onair,plugins` - tells coverage where the code under test exists for reporting line hits -`-m pytest` - tells coverage what test runner (framework) to wrap -`./test` - run all tests found in this directory and subdirectories +`python -m` - invokes the python runtime on the library following the -m +`coverage run` - runs coverage data collection during testing, wrapping itself on the test runner used +`--branch` - includes code branching information in the coverage report +`--source=onair,plugins` - tells coverage where the code under test exists for reporting line hits +`-m pytest` - tells coverage what test runner (framework) to wrap +`./test` - run all tests found in this directory and subdirectories #### A few optional settings for the command line Options that may be added to the command line test run. Use these at your own discretion. -`--disable-warnings` - removes the warning reports, but displays count (i.e., 124 passed, 1 warning in 0.65s) -`-p no:randomly` - ONLY required to stop random order testing IFF pytest-randomly installed -`--conftest-seed=###` - set the random values seed for this run -`--randomly-seed=###` - set the random order seed for this run -`--verbose` or `-v` - set verbosity level, also -vv, -vvv, etc. -`-k KEYWORD` - only run tests that match the KEYWORD (see `pytest --help`) +`--disable-warnings` - removes the warning reports, but displays count (i.e., 124 passed, 1 warning in 0.65s) +`-p no:randomly` - ONLY required to stop random order testing IFF pytest-randomly installed +`--conftest-seed=###` - set the random values seed for this run +`--randomly-seed=###` - set the random order seed for this run +`--verbose` or `-v` - set verbosity level, also -vv, -vvv, etc. +`-k KEYWORD` - only run tests that match the KEYWORD (see `pytest --help`) NOTE: see note about seeds in driver.py section above ### To view testing line coverage after run: NOTE: you may or may not need the `python -m` to run coverage report or html -`coverage report` - prints basic results in terminal -or -`coverage html` - creates htmlcov/index.html, automatic when using driver.py for testing +`coverage report` - prints basic results in terminal +or +`coverage html` - creates htmlcov/index.html, automatic when using driver.py for testing then ` htmlcov/index.html` - browsable coverage (i.e., `firefox htmlcov/index.html`) +## Running with Core Flight System (cFS) +OnAIR can be setup to subscribe to and recieve messages from cFS. For more information see [doc/cfs-onair-guide.md](doc/cfs-onair-guide.md) + ## License and Copyright Please refer to [NOSA GSC-19165-1 OnAIR.pdf](NOSA%20GSC-19165-1%20OnAIR.pdf) and [COPYRIGHT](COPYRIGHT). @@ -205,6 +208,7 @@ We are a small team, but will try to respond in a timely fashion. If you would like to contribute to the repository, GREAT! First you will need to complete the [Individual Contributor License Agreement (pdf)](doc/Indv_CLA_OnAIR.pdf). Then, email it to gsfc-softwarerequest@mail.nasa.gov with james.marshall-1@nasa.gov CCed. +Please include your github username in the email. Next, please create an issue for the fix or feature and note that you intend to work on it. Fork the repository and create a branch with a name that starts with the issue number. diff --git a/doc/cfs-onair-guide.md b/doc/cfs-onair-guide.md new file mode 100644 index 00000000..9912ac5b --- /dev/null +++ b/doc/cfs-onair-guide.md @@ -0,0 +1,104 @@ +# Using OnAIR with Core Flight System (cFS) + +## Overview + +In order for OnAIR to receive cFS messages, cFS must include the Software Bus Network (SBN) app and the SBN Client library in its tree. + +SBN is a cFS application that enables pub/sub across the software busses (SB) of multiple cFS instances: https://github.com/nasa/SBN + +SBN Client is an external library that implements the SBN protocol. This allows other environments, including Python, to communicate with a Software Bus via the SBN: https://github.com/nasa/SBN-Client + +In OnAIR, the sbn_adapter DataSource uses SBN Client to subscribe to messages on the Software Bus. When cFS messages are received, it extracts the data from the message structs and passes it up to the rest of the OnAIR system. + +## Example +An example distribution of cFS integrated with OnAIR can be found [here.](https://github.com/the-other-james/cFS/tree/OnAIR-integration) + +In this example, OnAIR is configured to the subscribe to the sample_app house keeping telemetry packet, `SAMPLE_APP_HkTlm_t`. + +### Quick Start +Requirements: Docker + +After cloning the repository, use `docker compose` to build and run a container called `cfs-onair` which should have both cFS and OnAIR dependencies set up. + +``` bash +docker compose up -d +``` + +After building, Docker will start the `cfs-onair` container in the background. Attach to the container using `docker exec`. + +```bash +docker exec -it cfs-onair bash +``` + +Inside the `cfs-onair` container, use the build script to build cFS. SBN, SBN Client and OnAIR are added as cFS apps that will be built/installed by the cFS CMake system. + +``` bash +./_build.sh +``` + +Then navigate to the install directory and run cFS. + +``` bash +cd build/exe/cpu1 +./core-cpu1 +``` + +Open a new terminal. Then attach to the same `cfs-onair` container and navigate to the same intall directory. + +``` bash +docker exec -it cfs-onair bash +cd build/exe/cpu1 +``` + +In this example, the build system copies OnAIR, the OnAIR telemetry metadata and config files, the ctypes structs of the cFS messages and the `sbn_python_client` python module from `sbn_client` files to `cf/`. The `sbn-client.so` binary is also installed to the `cf/` directory. + +Now OnAIR can be run from the install directory `build/exe/cpu1`. + +```bash +python3 cf/onair/driver.py cf/onair/cfs_sample_app.ini +``` + +If OnAIR successfully connects to cFS via SBN Client and SBN, you should see the following start up log + +``` +SBN Adapter ignoring data file (telemetry should be live) +SBN Client library loaded: '' +SBN_Client Connecting to 127.0.0.1, 2234 + +SBN Client init: 0 +SBN Client command pipe: 0 +SBN_Adapter Running +SBN Client subscribe msg (id 0x883): 0 + +*************************************************** +************ SIMULATION STARTED ************ +*************************************************** +App message received: MsgId 0x00000883 +Payload: +``` + +### Explanation + +This repo is essentially the base distribution of cFS with three additional `apps` added, `sbn`, `sbn_client` and `onair_app`. While SBN_Client and OnAIR can exist outside of cFS as standalone projects, they were added as cFS apps to take advantage of the cFS build system. + +### SBN and SBN Client +SBN and SBN Client are added as submodules in the `apps/` folder. They are added to the build system by appending them to the global app list found in the `targets.cmake` file. Since SBN is an actual cFS app it also needs to be added to the cfe start up script, `cpu1-cfe_es_startup.scr`. When SBN Client is built, it results in `sbn-client.so` binary that other processes, such as OnAIR, will use to communicate with the SBN. + +By default, SBN's configuration table (`sbn_conf_tbl.c`) will assign the address and port `127.0.0.1:2234` to this instance of the SBN app. In order for SBN_Client to talk to SBN it must use the same address, which is set in `sbn_client_defs.h`. + +### OnAIR +An additional folder, called `onair_app/` is also added to the `apps/` folder. This folder contains the OnAIR source directory as a submodule, as well as a telemetry metadata file, config file, and a message_headers.py file. The `onair_app` also has a `CMakeLists.txt` file directing how it should be built by the cFS build system. In this case it just copies the files to the target directory. + +`s_o.ini` - this is the config file. It specifies the name/location of the telemetry metadata file and selects `sbn_adapter` as the OnAIR Datasource. + +`s_o_TLM_CONFIG.json` - this is the telemetry metadata file. It lists each data field that OnAIR is interested in as well as their order. Most importantly, in the `channels` field it matches the cFS message ID with name of the message and the name of the actual message struct. `sbn_adapter` will subscribe to the message IDs listed here. When a message is received `sbn_adapter` will use the received message IDs to determine the structure of the message so it can correctly unpack it. + +``` + "channels":{ + "0x0883": ["SAMPLE_APP", "SAMPLE_APP_HkTlm_t"] + } +``` + +`message_headers.py` - this python file is unqiue to `sbn_adapter.py` (i.e its not needed anywhere else in OnAIR). It defines the message structs that will be received using python ctypes. In this example `message_headers.py` contains the sample app house keeping message structs found in `sample_app/fsw/src/sample_app_msg.h`. + + diff --git a/onair/config/sbn_cfs_config.ini b/onair/config/sbn_cfs_config.ini new file mode 100644 index 00000000..eb5ac079 --- /dev/null +++ b/onair/config/sbn_cfs_config.ini @@ -0,0 +1,14 @@ +[DEFAULT] +TelemetryDataFilePath = +TelemetryFile = +TelemetryMetadataFilePath = cf/onair/onair/data/telemetry_configs/ +MetaFile = adapter_TLM_CONFIG.json +ParserFileName = cf/onair/onair/data_handling/sbn_adapter.py + +KnowledgeRepPluginDict = {'generic':'cf/onair/plugins/generic/__init__.py'} +LearnersPluginDict = {'generic':'cf/onair/plugins/generic/__init__.py'} +PlannersPluginDict = {'generic':'cf/onair/plugins/generic/__init__.py'} +ComplexPluginDict = {'generic':'cf/onair/plugins/generic/__init__.py'} + +[RUN_FLAGS] +IO_Flag = true diff --git a/onair/data/telemetry_configs/adapter_TLM_CONFIG.json b/onair/data/telemetry_configs/adapter_TLM_CONFIG.json index 2656ce1b..e002b755 100644 --- a/onair/data/telemetry_configs/adapter_TLM_CONFIG.json +++ b/onair/data/telemetry_configs/adapter_TLM_CONFIG.json @@ -115,5 +115,11 @@ "SAMPLE.sample_data_gps_t.sample_data_lat", "SAMPLE.sample_data_gps_t.sample_data_lng", "SAMPLE.sample_data_gps_t.sample_data_alt" - ] -} \ No newline at end of file + ], + "channels": + { + "0x0887": ["SAMPLE", "sample_data_power_t"], + "0x0889": ["SAMPLE", "sample_data_thermal_t"], + "0x088A": ["SAMPLE", "sample_data_gps_t"] + } +} diff --git a/onair/data_handling/sbn_adapter.py b/onair/data_handling/sbn_adapter.py new file mode 100644 index 00000000..fafaa671 --- /dev/null +++ b/onair/data_handling/sbn_adapter.py @@ -0,0 +1,188 @@ +# GSC-19165-1, "The On-Board Artificial Intelligence Research (OnAIR) Platform" +# +# Copyright © 2023 United States Government as represented by the Administrator of +# the National Aeronautics and Space Administration. No copyright is claimed in the +# United States under Title 17, U.S. Code. All Other Rights Reserved. +# +# Licensed under the NASA Open Source Agreement version 1.3 +# See "NOSA GSC-19165-1 OnAIR.pdf" + +""" +SBN_Adapter class + +Receives messages from SBN, serves as a data source for sim.py +""" + +import threading +import time +import datetime +import os +import json + +from onair.data_handling.on_air_data_source import OnAirDataSource +from onair.data_handling.on_air_data_source import ConfigKeyError +from ctypes import * +import sbn_python_client as sbn +import message_headers as msg_hdr + +from onair.data_handling.parser_util import * + +# Note: The double buffer does not clear between switching. If fresh data doesn't come in, stale data is returned (delayed by 1 frame) + +class DataSource(OnAirDataSource): + + def __init__(self, data_file, meta_file, ss_breakdown = False): + super().__init__(data_file, meta_file, ss_breakdown); + + self.new_data_lock = threading.Lock() + self.new_data = False + self.double_buffer_read_index = 0 + self.connect() + + def connect(self): + """Establish connection to SBN and launch listener thread.""" + time.sleep(2) + os.chdir("cf") + sbn.sbn_load_and_init() + os.chdir("../") + print("SBN_Adapter Running") + + # Launch thread to listen for messages + self.listener_thread = threading.Thread(target=self.message_listener_thread) + self.listener_thread.start() + + # subscribe to message IDs + for msgID in self.msgID_lookup_table.keys(): + sbn.subscribe(msgID) + + def gather_field_names(self, field_name, field_type): + + # recursively find field names in DFS manner + def gather_field_names_helper(field_name:str, field_type, field_names:list): + if "message_headers" in str(field_type) and hasattr(field_type, "_fields_"): + for sub_field_name, sub_field_type in field_type._fields_: + gather_field_names_helper(field_name + "." + sub_field_name, sub_field_type,field_names) + else: + field_names.append(field_name) + + field_names = [] + gather_field_names_helper(field_name, field_type, field_names) + return field_names + + def parse_meta_data_file(self, meta_data_file, ss_breakdown): + self.msgID_lookup_table = {} + self.currentData = [] + + # pull out message ids + file = open(meta_data_file, 'rb') + file_str = file.read() + + meta_config = json.loads(file_str) + file.close() + + if 'channels' not in meta_config.keys(): + raise ConfigKeyError(f'Config file: \'{meta_data_file}\' ' \ + 'missing required key \'channels\'') + + # Copy message ID table from .json, convert string hex to ints for ID + for key in meta_config['channels']: + self.msgID_lookup_table[int(key, 16)] = meta_config['channels'][key] + + # Use eval() to convert class name from .json to match with message_headers.py + for key in self.msgID_lookup_table: + msg_struct_name = self.msgID_lookup_table[key][1] + self.msgID_lookup_table[key][1] = eval("msg_hdr." + msg_struct_name) + + # populate headers and reserve space for data + for x in range(0,2): + self.currentData.append({'headers':[], 'data':[]}) + + for msgID in self.msgID_lookup_table.keys(): + app_name, data_struct = self.msgID_lookup_table[msgID] + struct_name = data_struct.__name__ + # Skip the header, walk through the stuct + for field_name, field_type in data_struct._fields_[1:]: + field_names = self.gather_field_names(app_name + "." + field_name, field_type) + for field_name in field_names: + self.currentData[x]['headers'].append(field_name) + self.currentData[x]['data'].append([0]) #initialize all the data arrays with zero + + return extract_meta_data_handle_ss_breakdown(meta_data_file, ss_breakdown) + + def process_data_file(self, data_file): + print("SBN Adapter ignoring data file (telemetry should be live)") + + def get_vehicle_metadata(self): + return self.all_headers, self.binning_configs['test_assignments'] + + def get_next(self): + """Provides the latest data from SBN in a dictionary of lists structure. + Returned data is safe to use until the next get_next call. + Blocks until new data is available.""" + + data_available = False + + while not data_available: + with self.new_data_lock: + data_available = self.has_data() + + if not data_available: + time.sleep(0.01) + + read_index = 0 + with self.new_data_lock: + self.new_data = False + self.double_buffer_read_index = (self.double_buffer_read_index + 1) % 2 + read_index = self.double_buffer_read_index + + return self.currentData[read_index]['data'] + + def has_more(self): + """Returns true if the adapter has more data. + For now always true: connection should be live as long as cFS is running. + TODO: allow to detect if cFS/the connection has died""" + return True + + def message_listener_thread(self): + """Thread to listen for incoming messages from SBN""" + + while(True): + generic_recv_msg_p = POINTER(sbn.sbn_data_generic_t)() + sbn.recv_msg(generic_recv_msg_p) + + msgID = generic_recv_msg_p.contents.TlmHeader.Primary.StreamId + app_name, data_struct = self.msgID_lookup_table[msgID] + + recv_msg_p = POINTER(data_struct)() + recv_msg_p.contents = generic_recv_msg_p.contents + recv_msg = recv_msg_p.contents + + # prints out the data from the message to the terminal + print(", ".join([field_name + ": " + str(getattr(recv_msg, field_name)) for field_name, field_type in recv_msg._fields_[1:]])) + + # TODO: Lock needed here? + self.get_current_data(recv_msg, data_struct, app_name) + + def get_current_data(self, recv_msg, data_struct, app_name): + # TODO: Lock needed here? + current_buffer = self.currentData[(self.double_buffer_read_index + 1) %2] + + # Skip the header, walk through the stuct + for field_name, field_type in recv_msg._fields_[1:]: + field_names = self.gather_field_names(field_name, field_type) + + for name in field_names: + idx = current_buffer['headers'].index(app_name + "." + name) + # Pull the data out of the message buy walking down the nested types + data = "" + current_object = recv_msg + for sub_type in name.split('.'): + current_object = getattr(current_object, sub_type) + data = str(current_object) # note does not work for arrays? + current_buffer['data'][idx] = data + + with self.new_data_lock: + self.new_data = True + + def has_data(self): + return self.new_data diff --git a/test/onair/data_handling/test_sbn_adapter.py b/test/onair/data_handling/test_sbn_adapter.py new file mode 100644 index 00000000..df4d141a --- /dev/null +++ b/test/onair/data_handling/test_sbn_adapter.py @@ -0,0 +1,521 @@ +# GSC-19165-1, "The On-Board Artificial Intelligence Research (OnAIR) Platform" +# +# Copyright © 2023 United States Government as represented by the Administrator of +# the National Aeronautics and Space Administration. No copyright is claimed in the +# United States under Title 17, U.S. Code. All Other Rights Reserved. +# +# Licensed under the NASA Open Source Agreement version 1.3 +# See "NOSA GSC-19165-1 OnAIR.pdf" + +# testing packages +import pytest +from unittest.mock import MagicMock, PropertyMock + +# mock dependencies of sbn_adapter.py +import sys +sys.modules['sbn_python_client'] = MagicMock() +sys.modules['message_headers'] = MagicMock() + +import onair.data_handling.sbn_adapter as sbn_adapter +from onair.data_handling.sbn_adapter import DataSource +from onair.data_handling.on_air_data_source import OnAirDataSource +from onair.data_handling.on_air_data_source import ConfigKeyError + +import threading +import datetime +import copy +import json + +# __init__ tests +def test_sbn_adapter_DataSource__init__sets_values_then_connects(mocker): + # Arrange + arg_data_file = MagicMock() + arg_meta_file = MagicMock() + arg_ss_breakdown = MagicMock() + + fake_new_data_lock = MagicMock() + + cut = DataSource.__new__(DataSource) + + mocker.patch.object(OnAirDataSource, '__init__', new=MagicMock()) + mocker.patch('threading.Lock', return_value=fake_new_data_lock) + mocker.patch.object(cut, 'connect') + + # Act + cut.__init__(arg_data_file, arg_meta_file, arg_ss_breakdown) + + # Assert + assert OnAirDataSource.__init__.call_count == 1 + assert OnAirDataSource.__init__.call_args_list[0].args == (arg_data_file, arg_meta_file, arg_ss_breakdown) + assert cut.new_data_lock == fake_new_data_lock + assert cut.new_data == False + assert cut.double_buffer_read_index == 0 + assert cut.connect.call_count == 1 + assert cut.connect.call_args_list[0].args == () + +# connect tests +def test_sbn_adapter_DataSource_connect_starts_listener_thread_and_subscribes_to_messages(mocker): + # Arrange + cut = DataSource.__new__(DataSource) + mocker.patch(sbn_adapter.__name__+'.time.sleep') + mocker.patch(sbn_adapter.__name__+'.os.chdir') + mocker.patch(sbn_adapter.__name__+'.sbn.sbn_load_and_init') + fake_listener_thread = MagicMock() + mocker.patch(sbn_adapter.__name__+'.threading.Thread', return_value = fake_listener_thread) + mocker.patch(sbn_adapter.__name__+'.sbn.subscribe') + + + fake_msgID_lookup_table = {} + n_ids = pytest.gen.randint(0,9) + while len(fake_msgID_lookup_table) < n_ids: + fake_id = pytest.gen.randint(0,1000) + fake_msgID_lookup_table[fake_id] = "na" + + cut.__setattr__('msgID_lookup_table',fake_msgID_lookup_table) + + # Act + cut.connect() + + # Assert + assert sbn_adapter.time.sleep.call_count == 1 + assert sbn_adapter.os.chdir.call_count == 2 + assert sbn_adapter.os.chdir.call_args_list[0].args == ("cf",) + assert sbn_adapter.os.chdir.call_args_list[1].args == ("../",) + assert sbn_adapter.threading.Thread.call_count == 1 + assert fake_listener_thread.start.call_count == 1 + assert sbn_adapter.sbn.subscribe.call_count == n_ids + + subbed_message_ids = set() + for call in sbn_adapter.sbn.subscribe.call_args_list: + for arg in call.args: + subbed_message_ids.add(arg) + + assert subbed_message_ids == set(fake_msgID_lookup_table.keys()) + +# gather_field_names tests +def test_sbn_adapter_DataSource_gather_field_names_returns_field_name_if_type_not_defined_in_message_headers_and_no_subfields_available(mocker): + # Arrange + cut = DataSource.__new__(DataSource) + + field_name = MagicMock() + field_type = MagicMock() + + # field type was not defined in message_headers.py and has no subfields of its own + field_type.__str__ = MagicMock() + field_type.__str__.return_value = 'fooble' + del field_type._fields_ + + # Act + result = cut.gather_field_names(field_name, field_type) + + # Assert + assert result == [field_name] + +def test_sbn_adapter_Data_Source_gather_field_names_returns_nested_list_for_nested_structure(mocker): + # Arrange + cut = DataSource.__new__(DataSource) + + # parent field has two child fields. + # The first child field has a grandchild field + parent_field_name = "parent_field" + parent_field_type = MagicMock() + child1_field_name = "child1_field" + child1_field_type = MagicMock() + child2_field_name = "child2_field" + child2_field_type = MagicMock() + gchild_field_name = "gchild_field" + gchild_field_type = MagicMock() + + gchild_field_type.__str__ = MagicMock() + gchild_field_type.__str__.return_value = "message_headers.mock_data_type" + del gchild_field_type._fields_ + + child2_field_type.__str__ = MagicMock() + child2_field_type.__str__.return_value = "message_headers.mock_data_type" + del child2_field_type._fields_ + + child1_field_type.__str__ = MagicMock() + child1_field_type.__str__.return_value = "message_headers.mock_data_type" + child1_field_type._fields_ = [(gchild_field_name, gchild_field_type)] + + parent_field_type.__str__ = MagicMock() + parent_field_type.__str__.return_value = "message_headers.mock_data_type" + parent_field_type._fields_ = [(child1_field_name, child1_field_type), + (child2_field_name, child2_field_type)] + + # act + result = cut.gather_field_names(parent_field_name, parent_field_type) + + # assert + assert isinstance(result, list) + assert len(result) == 2 + assert set(result) == set([parent_field_name + '.' + child2_field_name, + parent_field_name + '.' + child1_field_name+ '.' +gchild_field_name]) + +# parse_meta_data_file tests +def test_sbn_adapter_DataSource_parse_meta_data_file_calls_rasies_ConfigKeyError_when_channels_not_in_config(mocker): + # Arrange + cut = DataSource.__new__(DataSource) + arg_meta_data_file = MagicMock() + arg_ss_breakdown = MagicMock() + + mocker.patch(sbn_adapter.__name__ + '.json.loads', return_value = {}) + + # Act + with pytest.raises(ConfigKeyError) as e_info: + cut.parse_meta_data_file(arg_meta_data_file,arg_ss_breakdown) + +def test_sbn_adapter_DataSource_parse_meta_data_file_populates_lookup_table_and_current_data_on_ideal_config(mocker): + # Arrange + cut = DataSource.__new__(DataSource) + arg_meta_data_file = MagicMock() + arg_ss_breakdown = MagicMock() + + ideal_config = { + "channels": { + "0x1": ["AppName1", "DataStruct1"], + "0x2": ["AppName2", "DataStruct2"] + } + } + + mock_struct_1 = MagicMock() + mock_struct_1.__name__ = "DataStruct1" + mock_struct_1._fields_ = [('TlmHeader', 'type0'), ('field1', 'type1')] + + mock_struct_2 = MagicMock() + mock_struct_2.__name__ = "DataStruct2" + mock_struct_2._fields_ = [('field0', 'type0'), ('field1', 'type1')] + + mocker.patch('message_headers.DataStruct1', mock_struct_1) + mocker.patch('message_headers.DataStruct2', mock_struct_2) + + mocker.patch('builtins.open', mocker.mock_open(read_data=json.dumps(ideal_config))) + mocker.patch('json.loads', return_value=ideal_config) + expected_configs = MagicMock() + mocker.patch(sbn_adapter.__name__ + '.extract_meta_data_handle_ss_breakdown', return_value = expected_configs) + + # Act + cut.parse_meta_data_file(arg_meta_data_file, arg_ss_breakdown) + print(cut.currentData) + + # Assert + assert cut.msgID_lookup_table == {1: ['AppName1', mock_struct_1], 2: ['AppName2', mock_struct_2]} + assert len(cut.currentData) == 2 + assert len(cut.currentData[0]['headers']) == 2 + assert len(cut.currentData[1]['headers']) == 2 + assert cut.currentData[0]['headers'] == ['AppName1.field1', 'AppName2.field1'] + assert cut.currentData[0]['data'] == [[0], [0]] + assert cut.currentData[1]['headers'] == ['AppName1.field1', 'AppName2.field1'] + assert cut.currentData[1]['data'] == [[0], [0]] + +# process_data_file tests +def test_sbn_adapter_DataSource_process_data_file_does_nothing(mocker): + # copied from test_redis_adapter.py + # test_redis_adapter_DataSource_process_data_file_does_nothing + cut = DataSource.__new__(DataSource) + arg_data_file = MagicMock() + + expected_result = None + + # Act + result = cut.process_data_file(arg_data_file) + + # Assert + assert result == expected_result + +# get_vehicle_metadata tests +def test_sbn_adapter_DataSource_get_vehicle_metadata_returns_list_of_headers_and_list_of_test_assignments(): + # copied from test_redis_adapter.py + # test_redis_adapter_DataSource_get_vehicle_metadata_returns_list_of_headers_and_list_of_test_assignments + + # Arrange + cut = DataSource.__new__(DataSource) + fake_all_headers = MagicMock() + fake_test_assignments = MagicMock() + fake_binning_configs = {} + fake_binning_configs['test_assignments'] = fake_test_assignments + + expected_result = (fake_all_headers, fake_test_assignments) + + cut.all_headers = fake_all_headers + cut.binning_configs = fake_binning_configs + + # Act + result = cut.get_vehicle_metadata() + + # Assert + assert result == expected_result + + +# get_next tests +def test_sbn_adapter_DataSource_get_next_returns_expected_data_when_new_data_is_true_and_double_buffer_read_index_is_0(): + # copied from test_redis_adapter.py + # test_redis_adapter_DataSource_get_next_returns_expected_data_when_new_data_is_true_and_double_buffer_read_index_is_0 + + # Arrange + # Renew DataSource to ensure test independence + cut = DataSource.__new__(DataSource) + cut.new_data = True + cut.new_data_lock = MagicMock() + cut.double_buffer_read_index = 0 + pre_call_index = cut.double_buffer_read_index + expected_result = MagicMock() + cut.currentData = [] + cut.currentData.append({'data': MagicMock()}) + cut.currentData.append({'data': expected_result}) + + # Act + result = cut.get_next() + + # Assert + assert cut.new_data == False + assert cut.double_buffer_read_index == 1 + assert result == expected_result + +def test_sbn_adapter_DataSource_get_next_returns_expected_data_when_new_data_is_true_and_double_buffer_read_index_is_1(): + # copied from test_redis_adapter.py + # test_redis_adapter_DataSource_get_next_returns_expected_data_when_new_data_is_true_and_double_buffer_read_index_is_1 + + # Arrange + # Renew DataSource to ensure test independence + cut = DataSource.__new__(DataSource) + cut.new_data = True + cut.new_data_lock = MagicMock() + cut.double_buffer_read_index = 1 + pre_call_index = cut.double_buffer_read_index + expected_result = MagicMock() + cut.currentData = [] + cut.currentData.append({'data': expected_result}) + cut.currentData.append({'data': MagicMock()}) + + # Act + result = cut.get_next() + + # Assert + assert cut.new_data == False + assert cut.double_buffer_read_index == 0 + assert result == expected_result + +def test_sbn_adapter_DataSource_get_next_when_called_multiple_times_when_new_data_is_true(): + # copied from test_redis_adapter.py + # test_redis_adapter_DataSource_get_next_when_called_multiple_times_when_new_data_is_true + + # Arrange + # Renew DataSource to ensure test independence + cut = DataSource.__new__(DataSource) + cut.double_buffer_read_index = pytest.gen.randint(0,1) + cut.new_data_lock = MagicMock() + cut.currentData = [MagicMock(), MagicMock()] + pre_call_index = cut.double_buffer_read_index + expected_data = [] + + # Act + results = [] + num_calls = pytest.gen.randint(2,10) # arbitrary, 2 to 10 + for i in range(num_calls): + cut.new_data = True + fake_new_data = MagicMock() + if cut.double_buffer_read_index == 0: + cut.currentData[1] = {'data': fake_new_data} + else: + cut.currentData[0] = {'data': fake_new_data} + expected_data.append(fake_new_data) + results.append(cut.get_next()) + + # Assert + assert cut.new_data == False + for i in range(num_calls): + results[i] = expected_data[i] + assert cut.double_buffer_read_index == (num_calls + pre_call_index) % 2 + +def test_sbn_adapter_DataSource_get_next_waits_until_new_data_is_available(mocker): + # copied from test_redis_adapter.py + # test_redis_adapter_DataSource_get_next_waits_until_new_data_is_available + + # Arrange + # Renew DataSource to ensure test independence + cut = DataSource.__new__(DataSource) + cut.new_data_lock = MagicMock() + cut.double_buffer_read_index = pytest.gen.randint(0,1) + pre_call_index = cut.double_buffer_read_index + expected_result = MagicMock() + cut.new_data = None + cut.currentData = [] + if pre_call_index == 0: + cut.currentData.append({'data': MagicMock()}) + cut.currentData.append({'data': expected_result}) + else: + cut.currentData.append({'data': expected_result}) + cut.currentData.append({'data': MagicMock()}) + + num_falses = pytest.gen.randint(1, 10) + side_effect_list = [False] * num_falses + side_effect_list.append(True) + + mocker.patch.object(cut, 'has_data', side_effect=side_effect_list) + mocker.patch(sbn_adapter.__name__ + '.time.sleep') + + # Act + result = cut.get_next() + + # Assert + assert cut.has_data.call_count == num_falses + 1 + assert sbn_adapter.time.sleep.call_count == num_falses + assert cut.new_data == False + if pre_call_index == 0: + assert cut.double_buffer_read_index == 1 + elif pre_call_index == 1: + assert cut.double_buffer_read_index == 0 + else: + assert False + + assert result == expected_result + +# has_more tests +def test_sbn_adapter_DataSource_has_more_always_returns_True(): + # copied from test_redis_adapter.py + # test_redis_adapter_DataSource_has_more_always_returns_True + cut = DataSource.__new__(DataSource) + assert cut.has_more() == True + +# mesage_listener_thread tests +def test_sbn_adapter_message_listener_thread_calls_get_current_data(mocker): + # Arrange + cut = DataSource.__new__(DataSource) + expected_app_name = MagicMock() + expected_data_struct = MagicMock() + fake_msg_id = "1234" + fake_lookup_table = {fake_msg_id: (expected_app_name, expected_data_struct)} + cut.__setattr__('msgID_lookup_table', fake_lookup_table) + + fake_generic_recv_msg_p = MagicMock() + fake_generic_recv_msg_p.contents = MagicMock() + fake_generic_recv_msg_p.contents.TlmHeader.Primary.StreamId = fake_msg_id + + fake_recv_msg_p = MagicMock() + fake_recv_msg_p.contents = 'not heyy' + + + def mock_POINTER_func(struct): + if struct == sbn_adapter.sbn.sbn_data_generic_t: + def return_func(): + return fake_generic_recv_msg_p + + elif struct == expected_data_struct: + def return_func(): + return fake_recv_msg_p + + else: + raise ValueError(f"Unexpected Struct {struct} used.") + + return return_func #return pointers wrapped in a function b/c that's how ctypes does it + + mocker.patch(sbn_adapter.__name__ + ".POINTER", side_effect = mock_POINTER_func) + + # for exiting the while loop + intentional_exception = KeyboardInterrupt('[TEST]: Exiting infinite loop') + mocker.patch.object(cut, 'get_current_data', side_effect = [intentional_exception]) + + # Act + with pytest.raises(KeyboardInterrupt) as e_info: + cut.message_listener_thread() + + # Assert + assert cut.get_current_data.call_count == 1 + assert fake_recv_msg_p.contents == fake_generic_recv_msg_p.contents + assert cut.get_current_data.call_args_list + expected_call = (fake_recv_msg_p.contents, expected_data_struct, expected_app_name) + assert cut.get_current_data.call_args_list[0].args == expected_call + +# get_current_data tests +def test_sbn_adapter_Data_Source_get_current_data_calls_gather_field_names_correctly(mocker): + # Arrange + cut = DataSource.__new__(DataSource) + cut.double_buffer_read_index = pytest.gen.randint(0,1) + n = pytest.gen.randint(1,9) + cut.currentData = [{'headers':[f'field_{i}' for i in range(n)],'data':[[0] for x in range(n)]}, + {'headers':[f'field_{i}' for i in range(n)],'data':[[0] for x in range(n)]}] + cut.new_data_lock = MagicMock() + + arg_recv_msg = MagicMock() + arg_recv_msg._fields_ = [(MagicMock(), MagicMock()) for x in range(n)] + arg_recv_msg._fields_.insert(0, 'header') + arg_recv_msg.TlmHeader.Secondary = MagicMock() + arg_recv_msg.TlmHeader.Secondary.Seconds = pytest.gen.randint(0,9) + arg_recv_msg.TlmHeader.Secondary.Subseconds = pytest.gen.randint(0,9) + + arg_data_struct = MagicMock() + arg_app_name = MagicMock() + + mocker.patch.object(cut, 'gather_field_names', return_value = []) + + # Act + cut.get_current_data(arg_recv_msg, arg_data_struct, arg_app_name) + + # Assert + assert cut.gather_field_names.call_count == n + assert len(cut.gather_field_names.call_args_list) == n + for i in range(n): + expected_args = arg_recv_msg._fields_[i+1] + assert cut.gather_field_names.call_args_list[i].args == expected_args + +def test_sbn_adapter_DataSource_get_current_data_unpacks_sub_fields_correctly(mocker): + # Arrange + cut = DataSource.__new__(DataSource) + cut.double_buffer_read_index = pytest.gen.randint(0,2) + cut.new_data_lock = MagicMock() + cut.new_data = MagicMock() + + # Message structure & data for 'fake_app' + arg_app_name = 'fake_app' + + arg_recv_msg = MagicMock() + arg_recv_msg._fields_ = [("TlmHeader", MagicMock()), ("field1", MagicMock())] + arg_recv_msg.TlmHeader.Secondary.Seconds = 0 + arg_recv_msg.TlmHeader.Secondary.Subseconds = 1 + arg_recv_msg.field1.temperature = 89 + arg_recv_msg.field1.voltage = 5 + arg_recv_msg.field1.velocity.x = 1 + arg_recv_msg.field1.velocity.y = 2 + + fake_field_names = [ + "field1.temperature", + "field1.voltage", + "field1.velocity.x", + "field1.velocity.y" + ] + mocker.patch.object(cut, 'gather_field_names', return_value = fake_field_names) + + # initialize double buffer + cut.__setattr__('currentData', [{'headers':[], 'data': []}, {'headers':[], 'data': []}]) + for x in range(0,2): + for name in fake_field_names: + cut.currentData[x]['headers'].append(arg_app_name + '.' + name) + cut.currentData[x]['data'].append([0]) + + expected_data = {'headers':[arg_app_name+'.'+"field1.temperature", + arg_app_name+'.'+"field1.voltage", + arg_app_name+'.'+"field1.velocity.x", + arg_app_name+'.'+"field1.velocity.y"], + 'data':['89','5','1','2'] } + + arg_data_struct = MagicMock() + + # Act + cut.get_current_data(arg_recv_msg, arg_data_struct, arg_app_name) + + # Assert + assert cut.currentData[(cut.double_buffer_read_index + 1) %2] == expected_data + assert cut.new_data == True + +# has_data tests +def test_sbn_adapter_DataSource_has_data_returns_instance_new_data(): + # copied from test_redis_adapter.py + # test_redis_adapter_DataSource_has_data_returns_instance_new_data + cut = DataSource.__new__(DataSource) + expected_result = MagicMock() + cut.new_data = expected_result + + result = cut.has_data() + + assert result == expected_result