Extending HMI with X calls in Rust

X call basics

HMI service can be extended with additional calls, called “X” calls.

When a client calls method x::svc_id::method, the following happens:

  • the target service gets EAPI calls to the method “x”

  • the call contains the following payload:

field

type

description

method

String

the target x-method of the service

params

Any

the target x-method params

aci

Map

ACI (API Call Info) data

acl

Map

the session ACL

  • the implemented method must check access control to resources manually if required

  • the result (or error) is returned to the client as-is

Service example

Let us create virtual sensors and allow clients to set item data using HTTP RPC calls to HMI, but for sensors only.

Create a virtual sensor, not assigned to real equipment:

eva item create sensor:tests/sensor1

Cargo.toml file:

[package]
name = "eva-svc-example-sensor-set"
version = "0.0.1"
edition = "2021"
publish = false

[dependencies]
eva-common = { version = "0.3", features = ["events", "common-payloads", "payload", "acl"] }
eva-sdk = { version = "0.3", features = ["hmi"] }
tokio = { version = "1.36.0", features = ["full"] }
async-trait = { version = "0.1.77" }
serde = { version = "1.0.196", features = ["derive", "rc"] }
log = "0.4.20"
jemallocator = { version = "0.5.4" }

[features]
std-alloc = []

Here is the service code, guided with comments in “X” call processing section. For the general information about services structure in Rust, read A simple service in Rust:

use eva_common::err_logger;
use eva_common::events::{RawStateEventOwned, RAW_STATE_TOPIC};
use eva_common::payload::{pack, unpack};
use eva_common::prelude::*;
use eva_sdk::hmi::XParamsOwned;
use eva_sdk::prelude::*;
use serde::Deserialize;

err_logger!();

const AUTHOR: &str = "Bohemia Automation";
const VERSION: &str = env!("CARGO_PKG_VERSION");
const DESCRIPTION: &str = "Sensor state manipulations";

#[cfg(not(feature = "std-alloc"))]
#[global_allocator]
static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;

// BUS/RT RPC handlers
struct Handlers {
    info: ServiceInfo,
}

#[async_trait::async_trait]
impl RpcHandlers for Handlers {
    async fn handle_call(&self, event: RpcEvent) -> RpcResult {
        svc_rpc_need_ready!();
        let method = event.parse_method()?;
        let payload = event.payload();
        match method {
            // handle extra calls from HMI
            "x" => {
                if payload.is_empty() {
                    Err(RpcError::params(None))
                } else {
                    let xp: XParamsOwned = unpack(payload)?;
                    match xp.method() {
                        "set" => {
                            #[derive(Deserialize)]
                            #[serde(deny_unknown_fields)]
                            struct Params {
                                i: OID,
                                status: ItemStatus,
                                #[serde(default)]
                                value: ValueOptionOwned,
                            }
                            // deserialize params
                            let params = Params::deserialize(xp.params)?;
                            // check if the item is a sensor
                            if params.i.kind() != ItemKind::Sensor {
                                return Err(Error::access("can set states for sensors only").into());
                            }
                            // check if the caller's session is not a read-only one
                            xp.aci.check_write()?;
                            // check if the caller's ACL has write access to the provided OID
                            xp.acl.require_item_write(&params.i)?;
                            // set the sensor state
                            // in this example the service does not check does the sensor really
                            // exist in the core or not
                            let mut event = RawStateEventOwned::new0(params.status);
                            event.value = params.value;
                            let topic = format!("{}{}", RAW_STATE_TOPIC, params.i.as_path());
                            eapi_bus::publish(&topic, pack(&event)?.into()).await?;
                            Ok(None)
                        }
                        _ => Err(RpcError::method(None)),
                    }
                }
            }
            _ => svc_handle_default_rpc(method, &self.info),
        }
    }
}

// The service configuration must be empty
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Config {}

#[svc_main]
async fn main(mut initial: Initial) -> EResult<()> {
    let _config: Config = Config::deserialize(
        initial
            .take_config()
            .ok_or_else(|| Error::invalid_data("config not specified"))?,
    )?;
    let info = ServiceInfo::new(AUTHOR, VERSION, DESCRIPTION);
    eapi_bus::init(&initial, Handlers { info }).await?;
    initial.drop_privileges()?;
    eapi_bus::init_logs(&initial)?;
    svc_start_signal_handlers();
    eapi_bus::mark_ready().await?;
    info!("{} started ({})", DESCRIPTION, initial.id());
    eapi_bus::block().await;
    eapi_bus::mark_terminating().await?;
    Ok(())
}

Service template

The following template can be used to quickly create a service instance with eva-shell:

eva svc create my.svc.sensor_set svc-tpl.yml
command: path/to/eva-svc-example-sensor-set
bus:
  path: var/bus.ipc
config: {}
user: nobody
workers: 1

HTTP API call example

The service responds to the following API calls (httpie call example):

(
cat <<EOF
{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "x::my.svc.sensor_set::set",
    "params": {
        "k": "mykey",
        "i": "sensor:tests/sensor1",
        "status": 1,
        "value": 25
        }
}
EOF
) | http :7727

If using EVA ICS WebEngine, the call can be made as:

eva.call(
    'x::my.svc.sensor_set::set',
    'sensor:tests/sensor1',
    { status: 1, value: 25 }
);