Payload Services

Payload services are essentially hardware services which have been custom designed for mission payload hardware. They share the same architecture as the hardware services, exposing low-level device APIs through a GraphQL interface.

The examples folder of the Kubos repo includes two example payload services: one written in Python, and one written in Rust.

Python Service

Inside of the Python service example you will find several files and a folder:

  • config.yml - This YAML file holds configuration options for the GraphQL/HTTP endpoint.

  • README.rst - Overview of the service project.

  • requirements.txt - Python module requirements file with list of module/version dependencies.

  • service.py - Boilerplate service code which reads the config file and starts up the GraphQL/HTTP endpoint.

  • service/ - This folder holds the guts of the service’s source. The service folder contains the main files which will need modifying when building a custom payload service:

    • __init__.py - This empty file belongs in the service/ folder to give service.py access to the modules within.
    • app.py - Another boilerplate service file. This one should not require any customization.
    • models.py - Describes the hardware model exposed to GraphQL and contains calls down into lower level APIs.
    • schema.py - Contains the actual GraphQL models which are used to generate the GraphQL endpoint.

We will now take a closer look at models.py and schema.py to see what exactly it takes to expose a hardware API through the service.

models.py

Inside of the example models.py file there are Subsystem and Status classes. Both of these classes must be subclasses of graphql.ObjectType from the Graphene module.

The Subsystem class models the hardware that this service will be interacting with.

class Subsystem(graphene.ObjectType):
    """
    Model encapsulating subsystem functionality.
    """

    power_on = graphene.Boolean()

    def refresh(self):
        """
        Will hold code for refreshing the status of the subsystem
        model based on queries to the actual hardware.
        """

        print "Querying for subsystem status"
        self.power_on = not self.power_on

    def set_power_on(self, power_on):
        """
        Controls the power state of the subsystem
        """

        print "Sending new power state to subsystem"
        print "Previous State: %s" % self.power_on
        print "New State: %s" % power_on
        self.power_on = power_on
        return Status(status=True, subsystem=self)

Member variables can be added if any persistent data needs to be stored. Member functions are called by the GraphQL schema and are used to call into low-level device API functions.

The Status class is used to model any important information gathered from calling device API functions.

class Status(graphene.ObjectType):
    """
    Model representing execution status. This allows us to return
    the status of the mutation function alongside the state of
    the model affected.
    """

    status = graphene.Boolean()
    subsystem = graphene.Field(Subsystem)

Right now it just contains a status member which represents the status of the function call and a subsystem member which represents the current state of the Subsystem.

schema.py

Now lets look inside of schema.py. This file contains the models used by Graphene to create our GraphQL endpoint.

Queries

Queries allow us to fetch data from the subsystem. There is only one Query class needed in the schema.py file.

class Query(graphene.ObjectType):
    """
    Creates query endpoints exposed by graphene.
    """

    subsystem = graphene.Field(Subsystem)

    def resolve_subsystem(self, info):
        """
        Handles request for subsystem query.
        """

        _subsystem.refresh()
        return _subsystem

Any member variables of the type graphene.Field become top-level fields accessible by queries. Because we are using the Subsystem class, which is also a graphene.ObjectType, members of that class become accessible by queries. Each Graphene field requires a resolver function named resolve_fieldname which returns back an object of the field’s class type. In this case, we call _subsystem.refresh() to load the latest data into the global _subsystem object and return it.

The above class would enable the following query for subsystem power status:

{
    subsystem {
        powerOn
    }
}

Mutations

Mutations allow us to call functions on the subsystem which cause change or perform some action. Like the Query class we will only need one top level Mutation class.

class Mutation(graphene.ObjectType):
    """
    Creates mutation endpoints exposed by Graphene.
    """

    power_on = PowerOn.Field()

Like with the Query, each Field member becomes a top-level mutation. However for mutations we will create a new class for each mutation field.

class PowerOn(graphene.Mutation):
    """
    Creates mutation for Subsystem.PowerOn
    """

    class Arguments:
        power = graphene.Boolean()

    Output = Status

    def mutate(self, info, power):
        """
        Handles request for subsystem powerOn mutation
        """

        status = Status(status=True, subsystem=_subsystem)
        if power != None:
            status = _subsystem.set_power_on(power)

        return status

The Arguments class describe any argument fields needed for this mutation. The line Output = Status describes the class type this mutation should return. The mutate function performs the actual work of the mutation and must return back an object of the type specified in the Output line. The above classes enable the following mutation:

mutation {
    powerOn(power:false) {
        status
    }
}

Running the example

Getting the example service up and running is fairly simple. First, you must make sure you have the necessary Python dependencies installed. If you are using the Kubos SDK Vagrant box then these will already be installed. Otherwise, you will need to run pip install -r requirements.txt.

Once the dependencies are in place, you can run python service.py config.yml and the example service should begin. You will know that it is running if the command line output says * Running on http://0.0.0.1:5000/ (Press CTRL+C to quit). You can now point a web browser to http://127.0.0.1:5000/graphiql to access a graphical GraphQL interface. Here you can run queries and mutations against the GraphQL endpoints and see the results.

Note

If you are running the example from within the Vagrant box then you may need some additional configuration.

By default the Vagrant box does not forward any ports. In order to access the HTTP interface of the service running inside of the Vagrant box we need to forward the port it is using. To do so you will need to add the following line to your `Vagrantfile` (after Vagrant.configure("2") do |config|):

config.vm.network "forwarded_port", guest: 5000, host: 5000

Now restart the Vagrant box with vagrant reload. You should now have the ability to run the python service inside the Vagrant box and access it from the outside at http://127.0.0.1:5000.

Rust Service

This is a quick overview of the payload service written in Rust.

The current guide for working with Rust within the Kubos SDK can be found here.

Libraries

This payload service and future rust-based services will be written using the following external crate:

And one internal helper crate:

The Cargo.toml in the example payload service gives a good list of crate dependencies to start with.

Example Source

Example Source - GitHub

  • Cargo.lock - Cargo lock file

  • Cargo.toml - Cargo manifest file

  • src - Contains the actual Rust source.

    • main.rs - Contains setup code using the kubos-service crate. May need minor customization but not much.
    • model.rs - Describes the hardware model exposed to GraphQL and contains calls down to lowel-level APIs.
    • schema.rs - Contains the actual GraphQL schema models used to generate the GraphQL endpoint.

We will now take a closer look at model.rs and schema.rs and break down the pieces required to expose hardware APIs through the service.

model.rs

The model.rs file contains structures and functions used to wrap low-level device APIs and provide abstractions for the GraphQL schema to call into. Looking inside of the model.rs file you will see several struct declarations. We’ll start with the Subsystem:

pub struct Subsystem;

Here we have a struct which is used to model a subsystem. In this example the struct is given no member variables for persistence. All data is obtained through function calls for real-time results.

Here is an abbreviated set of functions implemented for the Subsystem struct:

impl Subsystem {
    /// Creates new Subsystem structure instance
    /// Code initializing subsystems communications
    /// would likely be placed here
    pub fn new() -> Subsystem {
        println!("getting new subsystem data");
        // Here we would call into a hardware API
        Subsystem {}
    }

    /// Power status getter
    /// Code querying for new power value
    /// could be placed here
    pub fn power(&self) -> Result<bool, Error> {
        println!("Getting power");
        // Low level query here
        Ok(true)
    }

    /// Power state setter
    /// Here we would call into the low level
    /// device function
    pub fn set_power(&self, _power: bool) -> Result<SetPower, Error> {
        println!("Setting power state");
        // Send command to device here
        if _power {
            Ok(SetPower { power: true })
        } else {
            Err(Error::new(
                ErrorKind::PermissionDenied,
                "I'm sorry Dave, I afraid I can't do that",
            ))
        }
    }
}

/// Overriding the destructor
impl Drop for Subsystem {
    /// Here is where we would clean up
    /// any subsystem communications stuff
    fn drop(&mut self) {
        println!("Destructing subsystem");
    }
}

The new function is the Subsystem constructor. It can be used to establish a connection with the hardware if necessary. This function is called once per query or mutation and produces the struct instance used.

The power function is an example of a function called during a query. These functions called by GraphQL functions must return the type Result<T, Error> in order to properly unpack valid data vs an error message.

The set_power function is an example of a function called during a mutation. It is essentially the same as power but takes a parameter. Functions called during mutations must also return the type Result<T, Error>.

The last function is the overridden destructor. This is not required but can be nice if you need to clean up any connections to the subsystem between queries.

In the model.rs file there are also several other very simple structs which don’t have any functions implemented for them: SetPower, ResetUptime, and CalibrateThermometer. These are used as wrappers around scalar values returned by various mutations in schema.rs.

schema.rs

Now we will take a look inside of schema.rs. This file contains the query and mutation models used by Juniper to create our GraphQL endpoints.

Queries

Queries allow us to fetch data from the subsystem. There is only one base Query struct needed in the schema.rs file.

pub struct QueryRoot;

/// Base GraphQL query model
graphql_object!(QueryRoot : Context as "Query" |&self| {
    field subsystem(&executor) -> FieldResult<&Subsystem>
        as "Subsystem query"
    {
        Ok(executor.context().get_subsystem())
    }
});

Inside of the graphql_object macro we define each top-level query field. In this case there is just the one subsystem field. In order to allow GraphQL access to the member functions (or variables) of the Subsystem struct we also apply the graphql_object macro to it:

/// GraphQL model for Subsystem
graphql_object!(Subsystem: Context as "Subsystem" |&self| {
    description: "Handler subsystem"

    field power() -> FieldResult<bool> as "Power state of subsystem" {
        Ok(self.power()?)
    }

    field uptime() -> FieldResult<i32> as "Uptime of subsystem" {
        Ok(self.uptime()?)
    }

    field temperature() -> FieldResult<i32> as "Temperature of subsystem" {
        Ok(self.temperature()?)
    }
});

Here we create GraphQL field wrappers around each member of the Subsystem struct that we want exposed. The syntax Ok(self.func()?) allows the translation of return type Result<T, Error> into FieldResult<T>.

Mutations

Mutations allow us to call functions on the subsystem which cause change or perform some action. Like the QueryRoot struct, we will only need one top-level MutationRoot struct:

pub struct MutationRoot;

/// Base GraphQL mutation model
graphql_object!(MutationRoot : Context as "Mutation" |&self| {

    // Each field represents functionality available
    // through the GraphQL mutations
    field set_power(&executor, power : bool) -> FieldResult<SetPower>
        as "Set subsystem power state"
    {
        Ok(executor.context().get_subsystem().set_power(power)?)
    }

});

Each top-level mutation is exposed as an individual field. For each mutation field there is a custom struct wrapping up the return values for that function. Each of these structs must also have the graphql_object macro applied to them.

/// GraphQL model for SetPower return
graphql_object!(SetPower: Context as "SetPower" |&self| {
    description: "Enable Power Return"

    field power() -> FieldResult<bool> as "Power state of subsystem" {
        Ok(self.power)
    }
});

These structs define fields which can then be used in the mutation to specify which return data is desired.

Building and Running

From inside of a Kubos SDK Vagrant box, navigate to the service folder of your copy of the Rust service example.

Issue cargo build in order to build the service.

Note

The cargo build command can be used to build any Rust service or crate from within the Vagrant box.

In order to run the service locally:

  • Verify that port 8000 is being forwarded out of your Vagrant box
  • Issue cargo run

Once it is up and running you can use the udp-service-client to issue queries or mutations against the service.