Skip to content

Code Samples

zagormiSICKAG edited this page Mar 27, 2025 · 3 revisions

Section covers code samples for working with various interfaces and TDC-X capabilities.

Digital Input Output Examples

In this section, the Digital Inputs/Outputs of the TDC-X device are discussed. Programming examples are given and thoroughly explained.

1. Go gRPC Example

In this section, a Go gRPC application is created and documented. A gRPC client is created from a Proto file to match the gRPC server the TDC-X device is serving. From it, the list of DIO devices, their input / output directions and states can be read and set. To that end, a simple Go application demonstrating said usage is created.

The application uses Golang's gRPC service to fetch and send data to the server.

1.1. Application Implementation

To implement a gRPC client, a Proto file matching the gRPC server's specifications is needed. This Proto file is then placed in a separate package, and from it, gRPC Go files are generated using the following commands:

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

export PATH="$PATH:$(go env GOPATH)/bin"

protoc --go_out=pkg/pb --go-grpc_out=pkg/pb pkg/dio.proto

This installs needed Protoc files, exports the path and generates files for the gRPC service. The application also requires an access token to connect to the gRPC service the TDC-X provides. Please see instructions on how to generate an access token for the TDC-X here: gRPC Usage.

Once you've obtained the token, navigate to grpc-dio/pkg/auth/token.json. Paste your generated token in the access_token field. This token is then used to create a context which will be used to create gRPC calls.

The main application first creates a new gRPC client that connects to the TDC-X IP address on port 8081. It does so by using the generated gRPC Go file specification.

conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		log.Fatalf("did not connect: %v", err)
	}
	defer conn.Close()

To demonstrate DIO functionality, the application lists all available DIO devices, then reads the current state of DIO_A before changing the state in an infinite for loop. A different DIO can be set by modifying the code. These functionalities require the generated gRPC code.

To list all DIO devices, an empty protos request is sent to the server. The client specifies the function ListDevices to receive all devices from the TDC-X device, which are then printed to the user.

req := &emptypb.Empty{}
res, err := client.ListDevices(context.Background(), req)

After reading the DIO device set to DIO_A in the example, a gRPC request for writing is created and sent to the gRPC server, setting the state to the opposite value.

switch currentState {
case "LOW":
	nextState = 2
case "HIGH":
	nextState = 1
default:
	nextState = 1
}

req := &protos.DigitalIOWriteRequest{
	Name:  DIODevice,
	State: nextState,
}

_, err := client.Write(context.Background(), req)
if err != nil {
	log.Fatalf("could not write: %v", err)
}

Reading and setting values is implemented in an infinite for loop which sleeps for two seconds before restarting the process.

1.2. Proto File

The Proto file used for the Digital Input/Output service is the following:

/**
 * DigitalIO Service.
 *
 * Service that enables a control of the Digital IO features of device
 */
syntax = "proto3";

package hal.digitalio;

import "google/protobuf/empty.proto";

option go_package = "./protos;protos";

enum IOType {
  INPUT = 0;
  OUTPUT = 1;
  BIDIRECTIONAL = 2;
}

enum IODirection {
  IN = 0;
  OUT = 1;
}

enum IOState {
  ERROR = 0;
  LOW = 1;
  HIGH = 2;
}

message IODevice {
  string name = 1;
  IOType type = 2;
  IODirection direction = 3;
}

/**
 * Represents the ListDevices response data.
 */
message DigitalIOListDeviceResponse {
  repeated IODevice devices = 1;
}

/**
 * Represents the SetDirection request data.
 */
message DigitalIOSetDirectionRequest {
  string name = 1;
  IODirection direction = 2;
}

/**
 * Represents the Read request data.
 */
message DigitalIOReadRequest {
  string name = 1;
}

/**
 * Represents the Read response data.
 */
message DigitalIOReadResponse {
  IOState state = 1;
}

/**
 * Represents the Write request data.
 */
message DigitalIOWriteRequest {
  string name = 1;
  IOState state = 2;
}

/**
 * Represents the Attach response data.
 */
message DigitalIOAttachResponse {
  string name = 1;
  IOState state = 2;
  int32 error = 3;
  string timestamp = 4;
}

/**
 * Service exposing DigitalIO functions.
 */
service DigitalIO {
  /// Used to retrieve all available digital IO devices.
  rpc ListDevices(google.protobuf.Empty) returns (DigitalIOListDeviceResponse) {}

  /// Used to set pin direction value of a particular gpio pin.
  rpc SetDirection(DigitalIOSetDirectionRequest) returns (google.protobuf.Empty) {}

  /// Used to read value of a particular gpio pin.
  rpc Read(DigitalIOReadRequest) returns (DigitalIOReadResponse) {}

  /// Used to write value of a particular gpio pin.
  rpc Write(DigitalIOWriteRequest) returns (google.protobuf.Empty) {}

  /// Used to stream input events form device.
  rpc Attach(google.protobuf.Empty) returns (stream DigitalIOAttachResponse) {}
}

To list all available Digital Input/Output services, use grpcurl, which is an open-source utility for accessing gRPC services via the shell. For help setting up the grpcurl command, see gRPC Usage.

To list all available Digital Input/Output calls, use the following line:

grpcurl -expand-headers -H 'Authorization: Bearer <token>' -emit-defaults -plaintext <device_ip>:<grpc_server_port> list hal.digitalio.DigitalIO

The token field is the fetched TDC-X authorization token. For help fetching this token, refer to gRPC Usage. The device-ip:grpc_server_port is the TDC-X IP address and the gRPC serving port. For example, if the token value was token and the address and port were 192.168.0.100:8081, you would use the following line to list all available Digital Input/Output services.

grpcurl -expand-headers -H 'Authorization: Bearer token' -emit-defaults -plaintext 192.168.0.100:8081 list hal.digitalio.DigitalIO

The response should be in this format:

hal.digitalio.DigitalIO.Attach
hal.digitalio.DigitalIO.ListDevices
hal.digitalio.DigitalIO.Read
hal.digitalio.DigitalIO.SetDirection
hal.digitalio.DigitalIO.Write

Additionally, you can use the gRPC Clicker VSCode extension for working with gRPC services. For help setting the service up, refer to gRPC Usage.

1.3. Application Deployment

1.2.1. Dockerfile

To deploy the application, a Go container should be created and deployed to the TDC-X. To that end, a Dockerfile is created. The file is shown below.

# build image for Go app
FROM golang:1.22.0 AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download
COPY . .

# setting environment
ENV GOOS=linux
ENV GOARCH=arm64

RUN go build -o dio-grpc ./cmd/main.go

# runtime image
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/dio-grpc .

CMD ["./dio-grpc"]

Open a terminal and paste the following commands:

docker build -t dio-grpc-app .
docker save -o dio-grpc-app.tar dio-grpc-app:latest

This will build the docker container and save the application as a .tar file which can be used for Portainer upload.

1.2.2. Dockerfile Breakdown

The Dockerfile first creates a build image that is used to build the Go application. It sets the working directory as /app, then copies the go.mod and go.sum files to the directory and downloads all needed files.

COPY go.mod go.sum ./
RUN go mod download
COPY . .

Next, the Go environment is set. This needs to be done as the TDC-X device has the arm64 architecture and is based on Linux. With this in mind, the application is set to the following:

ENV GOOS=linux
ENV GOARCH=arm64

The application is built as dio-grpc.

RUN go build -o dio-grpc ./cmd/main.go

Finally, a runtime image is created from the latest alpine version for a smaller image size. The working directory is once again set to /app, and the application is copied. The last line specifies that, upon deployment, the dio-grpc application is started.

FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/dio-grpc .

CMD ["./dio-grpc"]

1.2.3. Deploying to Portainer

To deploy the application to the TDC-X device, Portainer can be used. To see instructions on the process, refer to Working with Portainer. As soon as the image and container are set up, the application starts running.

Download the example code from the link below:

Download Example Code

2. Node-RED gRPC Example

This section describes the creation and usage of a Node-RED gRPC example using digital input / output devices. The application makes listing all DIO devices, reading DIO states, writing the DIO state and streaming changes in the DIO devices possible.

For implementation, the following nodes are used:

  • inject node
  • gRPC call node
  • debug node.

The gRPC node set is not part of the initial Node-RED package list and will have to be installed to the Palette. For gRPC node installation, import the following file in the Manage Palette section: Download Node

The gRPC node server needs to be set properly to be able to connect to the gRPC server and interpret server results correctly. The following configuration is used:

  • Server:192.168.0.100
  • Port:8081
  • a Proto File is provided

To test out any of the listed functionalities, use the inject node. The result should appear in the debug node.

See a screenshot of the application below:

DIO Node-RED Example

Download the example code from the link below:

Download Example Code

3. Lua Example

A Lua application is provided as a DIO usage example. The example demonstrates setting a DIO device as output or input, setting an output state and reading DI states.

The script prints the engine version at the start of the script. It then creates a DIO AO output and sets the value of the output to HIGH.

dioAO = Connector.DigitalOut.create('DIO_AO')
dioAO:set(true);
print("Set DO A output to HIGH.")

To set a DO output to LOW, uncomment the following lines:

dioAO:set(false);
print("Set DO A output to LOW.")

The script then sets three DIO devices to inputs (DIO_BI, DIO_CI and DIO_DI). The state of said inputs is then read using the following function:

function PrintDIOStateIn(dio, name)
    local state = dio:get()
    print(string.format("%s state: %s", name, state))
  end

Another way of reading the state is registering the DI onChangeStamped, printing the DI state on event.

dioDI = Connector.DigitalIn.create('DIO_DI')
function Print_DIODI()
    local state = dioDI:get()
    print(string.format("State of DIO_DI is: %s", state))
end

Connector.DigitalIn.register(dioDI, "OnChangeStamped", Print_DIODI)

See the result of the application run below.

[15:55:53.721: INFO: AppEngine] Starting app: 'dio' (priority: LOW)
Engine version: 0.8.3
Set DO A output to HIGH.
DIO BI state: false
DIO CI state: false
DIO_DI state: false

Download the example code from the link below:

Download Example Code

Analog Input Examples

In this section, the Analog Inputs of the TDC-X device are discussed. Programming examples are given and thoroughly explained.

1. Go gRPC Example

In this section, a Go gRPC application is created and documented. A gRPC client is created from a Proto file to match the gRPC server the TDC-X device is serving. From it, the list of AIN devices and their current value can be read. The application also has an example of how to change the AIN mode which can be used if uncommented. To that end, a simple Go application demonstrating said usage is created.

The application uses Golang's gRPC service to fetch and send data to the server.

1.1. Application Implementation

To implement a gRPC client, a Proto file matching the gRPC server's specifications is needed. This Proto file is then placed in a separate package, and from it, gRPC Go files are generated using the following commands:

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

export PATH="$PATH:$(go env GOPATH)/bin"

protoc --go_out=pkg/pb --go-grpc_out=pkg/pb pkg/ain.proto

This installs needed Protoc files, exports the path and generates files for the gRPC service. The application also requires an access token to connect to the gRPC service the TDC-X provides. Please see instructions on how to generate an access token for the TDC-X on gRPC Usage.

Once you've obtained the token, navigate to grpc-ain/pkg/auth/token.json. Paste your generated token in the access_token field. This token is then used to create a context which will be used to create gRPC calls.

The application starts by creating a new gRPC client that connect to the TDC-X address and the port 8081. The client creation is tied to the Proto file and generated gRPC files. This is done using the following code:

conn, err := grpc.NewClient("192.168.0.100:8081", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
	log.Fatalf("did not connect: %v", err)
}
defer conn.Close()

client := protos.NewAnalogINClient(conn)

The application then sends a request to the server that lists all AIN devices available on the device. This is done by sending an empty request with the protos function ListDevices defined in the generated Go files. The devices are printed to the terminal.

req := &emptypb.Empty{}
res, err := client.ListDevices(context.Background(), req)
if err != nil {
	log.Fatalf("could not write: %v", err)
}

The program then enters an infinite for loop that continuously checks the voltage or current value from the selected AIN device, sleeping for one second between each server call. The example is written using the AI_A device. The request is created by providing the correct device name parameter, which is sent to the server.

req := &protos.AnalogInReadRequest{
	Name: AINDevice,
}

res, err := client.Read(context.Background(), req)
if err != nil {
	log.Fatalf("could not read: %v", err)
}

The response is printed to the terminal and contains the following values:

  • adcRaw
  • Converted
  • Unit

To use the function for changing modes from VOLTAGE to CURRENT or vice-versa, uncomment the changeAINMode code blocks. The Mode variable in the code is set to Current. Set it to one of the following values:

  • AnalogInMode_Voltage
  • AnalogInMode_Current

1.2. Proto File

The Proto file used for the Analog Input service is the following:

/**
 * AnalogIn service.
 *
 * Service that enables a control of the AnalogIn features of device
 */
syntax = "proto3";

package hal.analogin;

import "google/protobuf/empty.proto";

option go_package = "./protos;protos";

enum AnalogInMode {
  Voltage = 0;
  Current = 1;
}

enum AnalogInUnits {
  V = 0;
  mA = 1;
}

message AnalogInDevice {
  string name = 1;
  AnalogInMode mode = 2;
}

/**
 * Represents the ListDevice response data.
 */
message AnalogInListDeviceResponse {
  repeated AnalogInDevice devices = 1;
}

/**
 * Represents the Read request data.
 */
message AnalogInReadRequest {
  string name = 1;
}

/**
 * Represents the Read response data.
 */
message AnalogInReadResponse {
  uint32 adcRaw = 1;
  float converted = 2;
  AnalogInUnits unit = 3;
}

/**
 * Represents the SetMeasureMode request data.
 */
message AnalogInSetMeasureModeRequest {
  string name = 1;
  AnalogInMode mode = 2;
}

message AnalogInAttachResponse {
  string channel = 1;
  string status = 2;
  string timestamp = 3;
}

/**
 * Service exposing AnalogIn functions.
 */
service AnalogIN {
  /// Used to retrieve all available analog input devices.
  rpc ListDevices(google.protobuf.Empty) returns (AnalogInListDeviceResponse) {}

  /// Used to read value of a particular analog input channel.
  rpc Read(AnalogInReadRequest) returns (AnalogInReadResponse) {}
  
  /// Used to set measure mode of a particular analog input channel.
  rpc SetMeasureMode(AnalogInSetMeasureModeRequest) returns (google.protobuf.Empty) {}

  /// Used to monitor for overcurrent events
  rpc Attach(google.protobuf.Empty) returns (stream AnalogInAttachResponse) {}
}

To list all available Analog Input services, use grpcurl, which is an open-source utility for accessing gRPC services via the shell. For help setting up the grpcurl command, refer to gRPC Usage.

To list all available Analog Input calls, use the following line:

grpcurl -expand-headers -H 'Authorization: Bearer <token>' -emit-defaults -plaintext <device_ip>:<grpc_server_port> list hal.analogin.AnalogIN

The token field is the fetched TDC-X authorization token. For help fetching this token, refer to gRPC Usage. The device-ip:grpc_server_port is the TDC-X IP address and the gRPC serving port. For example, if the token value was token and the address and port were 192.168.0.100:8081, you would use the following line to list all available Analog Input services.

grpcurl -expand-headers -H 'Authorization: Bearer token' -emit-defaults -plaintext 192.168.0.100:8081 list hal.analogin.AnalogIN

The response should be in this format:

hal.analogin.AnalogIN.Attach
hal.analogin.AnalogIN.ListDevices
hal.analogin.AnalogIN.Read
hal.analogin.AnalogIN.SetMeasureMode

Additionally, you can use the gRPC Clicker VSCode extension for working with gRPC services. For help setting the service up, refer to gRPC Usage.

1.3. Application Deployment

1.2.1. Dockerfile

To deploy the application, a Go container should be created and deployed to the TDC-X. To that end, a Dockerfile is created. The file is shown below.

# build image for Go app
FROM golang:1.22.0 AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download
COPY . .

# setting environment
ENV GOOS=linux
ENV GOARCH=arm64

RUN go build -o ain-grpc ./cmd/main.go

# runtime image
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/ain-grpc .

CMD ["./ain-grpc"]

Open a terminal and paste the following commands:

docker build -t ain-grpc-app .
docker save -o ain-grpc-app.tar ain-grpc-app:latest

This will build the docker container and save the application as a .tar file which can be used for Portainer upload.

1.2.2. Dockerfile Breakdown

The Dockerfile first creates a build image that is used to build the Go application. It sets the working directory as /app, then copies the go.mod and go.sum files to the directory and downloads all needed files.

COPY go.mod go.sum ./
RUN go mod download
COPY . .

Next, the Go environment is set. This needs to be done as the TDC-X device has the arm64 architecture and is based on Linux. With this in mind, the application is set to the following:

ENV GOOS=linux
ENV GOARCH=arm64

The application is built as ain-grpc.

RUN go build -o ain-grpc ./cmd/main.go

Finally, a runtime image is created from the latest alpine version for a smaller image size. The working directory is once again set to /app, and the application is copied. The last line specifies that, upon deployment, the ain-grpc application is started.

FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/ain-grpc .

CMD ["./ain-grpc"]

1.2.3. Deploying to Portainer

To deploy the application to the TDC-X device, Portainer can be used. To see instructions on the process, refer to Working with Portainer. As soon as the image and container are set up, the application starts running.

Download the example code from the link below:

Download Example Code

2. Node-RED gRPC Example

This section describes the creation and usage of a Node-RED gRPC example using analog inputs. The application makes listing all AIN devices, reading AIN values, changing AIN mode and streaming changes in the AIN devices possible.

For implementation, the following nodes are used:

  • inject node
  • gRPC call node
  • debug node.

The gRPC node set is not part of the initial Node-RED package list and will have to be installed to the Palette. For gRPC node installation, import the following file in the Manage Palette section: Download Node

The gRPC node server needs to be set properly to be able to connect to the gRPC server and interpret server results correctly. The following configuration is used:

  • Server:192.168.0.100
  • Port:8081
  • a Proto File is provided

To test out any of the listed functionalities, use the inject node. The result should appear in the debug node.

See a screenshot of the application below:

AIN Node-RED Example

Download the example code from the link below:

Download Example Code

3. Lua Example

A Lua application is provided as an AIN usage example. The example sets up three AIN devices (AI_A, AI_B, and AI_C) and sets their measure mode, then reads the measure mode, raw value and converted value of the specified AIN device.

The application starts with setting up three AINN devices.

local a1 = setupDevice('AI_A', 'Voltage', createCallback, nil)
local a2 = setupDevice('AI_B', 'Voltage', createCallback, nil)
local a3 = setupDevice('AI_C', 'Current', createCallback, 4)

The setupDevice function prints the current device name, creates an AIN handle, sets the measure mode of the device and provides the needed callback function.

local device = AnalogIn.create(deviceName)
device:setMeasureMode(initialMode)
device:register('Overcurrent', createCallback(device, countLimit))

Then it gets the device's measure mode, raw value and converted value and prints it to the user.

local measureMode = device:getMeasureMode()
print("Measure mode is set to: " .. measureMode)
local rawValue = device:readRaw()
print(string.format("Raw value is: %s", rawValue))
local convertedValue = device:readConverted()
print(string.format("Converted value is: %s", convertedValue))

The script prints the engine version at the end of the script. See the result of the application run below.

[13:53:36.283: INFO: AppEngine] Starting app: 'ain' (priority: LOW)
-----------Device is 'AI_A'---------------
Measure mode is set to: Voltage
Raw value is: 0
Converted value is: 0.00756137492
-----------Device is 'AI_B'---------------
Measure mode is set to: Voltage
Raw value is: 0
Converted value is: 0.00756137492
-----------Device is 'AI_C'---------------
Measure mode is set to: Current
Raw value is: 0
Converted value is: 0.0378068723
Engine version: 0.8.3

Download the example code from the link below:

Download Example Code

Serial Examples

In this section, the Serial interface of the TDC-X device and examples of its usage are discussed. Programming examples are given and thoroughly explained.

1. Go Example

This section handles setting up a serial device using gRPC, and creating serial Go applications.

The first application is made and tested with the Leaf Wetness Sensor, which operates with a Baudrate of 9600, using the communication protocol MODBUS, and with RS485. For this application, an Isolated Soil Sensor was used.

The second application was created by simulating a RS422 device using two TDC-X devices and connecting their serial interfaces. The reading and writing functionalities are implemented as separate applications.

1.1. Setting up Serial Device

In this subsection, setting up the serial device is discussed. Using the dedicated serial HAL service, setup of the following parameters is possible:

  • Mode
  • Transceiver Power
  • Slew Rate
  • Termination

In the following subsections, setting up the mode and termination of a serial device is discussed. This is done using the Serial HAL Service hal.serial.Serial. Examples using grpcurl are given below. For more information about using gRPC services, refer to gRPC Usage.

1.1.1. Setting Up Serial Mode

The Serial HAL service allows seting up the serial mode. There are two possible modes, each configured to set up your serial device according to its protocol. Possible modes include:

  • RS422
  • RS485

To set up your serial mode, use the hal.serial.Serial.SetMode service. An example of setting the mode to RS422 is given below.

grpcurl -emit-defaults -H 'Authorization: Bearer {token}' -plaintext -d '{"interfaceName":"serial","mode":"RS422"}' 192.168.0.100:8081 hal.serial.Serial.SetMode
{}

To check changes to the serial mode, use the HAL service hal.serial.Serial.GetMode.

grpcurl -emit-defaults -H 'Authorization: Bearer {token}' -plaintext -d '{"interfaceName":"serial"}' 192.168.0.100:8081 hal.serial.Serial.GetMode
{
  "mode": "RS422"
}

1.1.2. Setting Up Serial Termination

The Serial HAL service provides a means to set up serial termination. The HAL service hal.serial.Serial.SetTermination is used. An example of setting the termination of the connected serial device on is given below.

grpcurl -emit-defaults -H 'Authorization: Bearer {token}' -plaintext -d '{"interfaceName":"serial","enableTermination":"true"}' 192.168.0.100:8081 hal.serial.Serial.SetTermination
{}

Checking changes to the termination is done by using the hal.serial.Serial.GetTermination HAL service.

grpcurl -emit-defaults -H 'Authorization: Bearer {token}' -plaintext -d '{"interfaceName":"serial"}' 192.168.0.100:8081 hal.serial.Serial.GetTermination
{
  "terminationEnabled": true
}

1.1.3. Viewing Serial Statistics

The Serial HAL service provides a means to view serial statistics. The HAL service hal.serial.Serial.GetStatistics is used. An example of seeing serial statistics is given below.

grpcurl -d '{"interfaceName":"SERIAL"}' -H 'Authorization: Bearer token' -plaintext 192.168.0.100:8081 hal.serial.Serial.GetStatistics
{
  "txCount": "1050",
  "rxCount": "982"
}

1.2. RS485 Example

In this section, the RS485 application is implemented.

1.2.1. Application Implementation

The application uses a single .go file to run. The go.bug.st/serial package is used to work with the serial port on /dev/ttyUSB0. The port mode is set and the communication to the port is opened.

func setupPort() serial.Port {
	mode := &serial.Mode{
		BaudRate: 9600,
		Parity:   serial.NoParity,
		DataBits: 8,
	}
	port, err := serial.Open("/dev/ttyUSB0", mode)
	if err != nil {
		log.Fatal(err)
	}
	return port
}

A message for fetching the temperature and humidity of the sensor is created. For the Isolated Soil Sensor, a message in a specific format is sent to the serial port which prompts the sensor to return the required values.

To send a message to the serial port, the Write function is used.

_, err := port.Write(message)
	if err != nil {
		log.Fatalf("Error writing to serial port: %v", err)
	}

The program then sleeps for a second so that the sensor has enough time to process the message and send data back to the host. For reading the serial port data, the Read function is used.

response := make([]byte, 256)
	n, err := port.Read(response)
	if err != nil {
		log.Fatalf("Error reading from serial port: %v", err)
	}

The result is then printed and parsed to a human-readable format.

func parseValues(rawData []byte) {
	tempHigh := rawData[3]
	tempLow := rawData[4]
	temperatureRaw := (uint16(tempHigh) << 8) | uint16(tempLow)
	realTemperature := float64(temperatureRaw) / 10.0

	humidityHigh := rawData[5]
	humidityLow := rawData[6]
	humidityRaw := (uint16(humidityHigh) << 8) | uint16(humidityLow)
	realHumidity := float64(humidityRaw) / 10.0

	fmt.Printf("Temperature: %.2f °C\n", realTemperature)
	fmt.Printf("Humidity: %.2f %%\n", realHumidity)
}

The results are printed to the console.

1.2.2. Application Deployment

This section describes the Go application deployment.

1.2.2.1. Dockerfile

To deploy the application, a Go container should be created and deployed to the TDC-X. To that end, a Dockerfile is created. The file is shown below.

# build image for Go app
FROM golang:1.22.0 AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download
COPY . .

# setting environment
ENV GOOS=linux
ENV GOARCH=arm64

RUN go build -o modbus-serial .

# runtime image
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/modbus-serial .

CMD ["./modbus-serial"]

Open a terminal and paste the following commands:

docker build -t modbus-serial .
docker save -o modbus-serial.tar modbus-serial:latest

This will build the docker container and save the application as a .tar file which can be used for Portainer upload.

1.2.2.2. Dockerfile Breakdown

The Dockerfile first creates a build image that is used to build the Go application. It sets the working directory as /app, then copies the go.mod file to the directory, then downloads necessary files.

COPY go.mod go.sum ./
RUN go mod download
COPY . .

Next, the Go environment is set. This needs to be done as the TDC-X device has the arm64 architecture and is based on Linux. With this in mind, the application is set to the following:

ENV GOOS=linux
ENV GOARCH=arm64

The application image is built as modbus-serial.

RUN go build -o modbus-serial .

Finally, a runtime image is created from the latest alpine version for a smaller image size. The working directory is once again set to /app, and the application is copied. The last line specifies that, upon deployment, the serial application is started.

FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/modbus-serial .

CMD ["./modbus-serial"]

1.2.2.3. Deploying to Portainer

To deploy the application to the TDC-X device, Portainer can be used. To see instructions on the process, refer to Working with Portainer. As soon as the image and container are set up, the application starts running.

Download the example code from the link below:

Download Example Code

1.3. RS422 Example

In this section, the RS422 application is discussed.

1.3.1. Application Implementation

Both applications need to connect to the TDC-X device's port responsible for serial communication in order to read data sent via RS422. To achieve this, a function is created to connect to the /dev/ttyUSB0 port using the go.bug.st/serial Go package.

This function configures the serial connection, setting the parity, data bits (8), stop bits, and baud rate. It then attempts to open the /dev/ttyUSB0 port. If successful, the port is returned; otherwise, an error is logged, and the application is terminated.

func setupPort() serial.Port {
	mode := &serial.Mode{
		Parity:   serial.NoParity,
		DataBits: 8,
		StopBits: serial.OneStopBit,
		BaudRate: 9600,
	}

	port, err := serial.Open("/dev/ttyUSB0", mode)
	if err != nil {
		log.Fatal(err)
	}
	return port
}

Then, a simple goroutine is started for both the witing and reading application.

To read the data, the following function is used:

func readData(port serial.Port) {
	buf := make([]byte, 128)
	for {
		n, err := port.Read(buf)
		if err != nil {
			log.Printf("Error reading data: %v\n", err)
			return
		}

		if n > 0 {
			fmt.Printf("Received: %s\n", string(buf[:n]))
		}
	}
}

The readData function continuously reads from the port, storing the incoming data in a byte buffer. If data is received, it is printed to the console.

To send data through the port, the writeData function is used in the writer application:

func writeData(port serial.Port, message []byte) {
	for {
		_, err := port.Write(message)
		if err != nil {
			log.Printf("Error writing data: %v\n", err)
			return
		} else {
			fmt.Printf("Sent: %s\n", message)
		}
		time.Sleep(2 * time.Second)
	}
}

This function accepts the port and a message in byte format. In this example, the application sends a Hello world! message every two seconds. If writing to the port fails, an error message is logged.

1.3.2. Application Deployment

This section describes the Go application deployment.

1.3.2.1. Dockerfile

To deploy the applications, Go containers should be created and deployed to the TDC-X devices. To that end, a Dockerfile is created for both applications. As Dockerfiles are identical, this documentation will focus on showing a single Dockerfile, but to create two applications, make sure to rename the serial tag in the file. The file is shown below.

# build image for Go app
FROM golang:1.22.0 AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download
COPY . .

# setting environment
ENV GOOS=linux
ENV GOARCH=arm64

RUN go build -o serial .

# runtime image
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/serial .

CMD ["./serial"]

Open a terminal and paste the following commands:

docker build -t serial .
docker save -o serial.tar serial:latest

This will build the docker container and save the application as a .tar file which can be used for Portainer upload.

1.3.2.2. Dockerfile Breakdown

The Dockerfile first creates a build image that is used to build the Go application. It sets the working directory as /app, then copies the go.mod file to the directory, then downloads necessary files.

COPY go.mod go.sum ./
RUN go mod download
COPY . .

Next, the Go environment is set. This needs to be done as the TDC-X device has the arm64 architecture and is based on Linux. With this in mind, the application is set to the following:

ENV GOOS=linux
ENV GOARCH=arm64

The application image is built as serial.

RUN go build -o serial .

Finally, a runtime image is created from the latest alpine version for a smaller image size. The working directory is once again set to /app, and the application is copied. The last line specifies that, upon deployment, the serial application is started.

FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/serial .

CMD ["./serial"]

1.3.2.3. Deploying to Portainer

To deploy the application to the TDC-X device, Portainer can be used. To see instructions on the process, refer to Working with Portainer. As soon as the image and container are set up, the application starts running.

Download the example code from the link below:

Download Example Code

2. Lua Example

A Lua application is provided as a Serial link usage example. Two scripts are given; one writes data to the TDC-X serial port, while te other reads from this port. For RS485 communication, a DIGITUS USB to Serial Adapter is connected to the TDC-X device for reading and writing data. For RS422 communication, two TDC-X devices are connected via a serial interface.

To change between the RS422 and RS485 standards, find the following line of code in the .lua example and set it accordingly.

-- sets type of communication to [RS422 | RS485]
S1:setType("RS422")

2.1. Writing to the RS485 / RS422 device

The first .lua script writes data to the RS4XX device. It creates a serial connection to the device and sets its termination to false. The type of the device is set and baud rate is set to 115200.

S1 = SerialCom.create('SER1')
S1:setTermination(false)
  
S1:setType("RS485")
S1:setBaudRate(115200)

A connection is opened and a Hello world! message is created. Then, a timer is created to send this message to the RS485 device periodically every 5 seconds. The message is transmitted the following way:

local Retb = S1:transmit(message)
print("Transmitted " .. Retb .. " bytes.")

Note that a timer is implemented instead of a Sleep service. This is because Sleep will cause all code to wait for the specified time, while timers operate locally, meaning that only the rs-write.lua application script will sleep for the specified time.

The script prints the engine version at the end of the script. See the result of the application run below.

[15:52:06.041: INFO: AppEngine] Starting app: 'serial' (priority: LOW)
Transmitted 13 bytes.
Transmitted 13 bytes.
Transmitted 13 bytes.

Download the example code from the link below:

Download Example Code

2.2. Reading from the RS485 / RS422 device

The second .lua script reads data from the RS485 device. The script creates a serial connection, sets the termination to false and the baudrate to 115200, then opens the connection. A Callback function is created to read from the device.

S1:register('OnReceive', Callback)

All received data is then printed to the console.

function Callback()
  data = S1:receive(1000)
  print(data)
end

See the result of the application run below.

[16:23:45.843: INFO: AppEngine] Starting app: 'serial' (priority: LOW)
Received data: Hello world!
Received data: Welcome to TDC-X!

Download the example code from the link below:

Download Example Code

IMU Examples

In this section, the IMU sensor of the TDC-X device and examples of its usage are discussed. Programming examples are given and thoroughly explained.

1. Go gRPC Example

In this section, a Go gRPC application is created and documented. A gRPC client is created from a Proto file to match the gRPC server the TDC-X device is serving. From it, the list of IMU sensors can be read, sampling frequency changed and their stream of data read. To that end, a simple Go application demonstrating this usage is created.

The application uses Golang's gRPC service for fetching and sending data to the server.

1.1. Application Implementation

To implement a gRPC client, a Proto file matching the gRPC server's specifications is needed. This Proto file is then placed in a separate package, and from it, gRPC Go files are generated using the following commands:

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

export PATH="$PATH:$(go env GOPATH)/bin"

protoc --go_out=pkg/pb --go-grpc_out=pkg/pb pkg/imu.proto

This installs needed Protoc files, exports the path and generates files for the gRPC service. The application also requires an access token to connect to the gRPC service the TDC-X provides. Please see instructions on how to generate an access token for the TDC-X here: gRPC Usage.

Once you've obtained the token, navigate to grpc-dio/pkg/auth/token.json. Paste your generated token into the access_token field. This token is then used to create a context which will be used to create gRPC calls.

The application starts by creating a new gRPC client that connects to the TDC-X address and the port 8081. The client creation is tied to the Proto file and generated gRPC files. This is done using the following code:

conn, err := grpc.NewClient("192.168.0.101:8081", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
	log.Fatalf("failed to connect: %v", err)
}
defer conn.Close()

client := protos.NewImuClient(conn)

The application then sends a request to the server that lists all IMU sensors available on the device. This is done by sending an empty request with the protos function ListDevices defined in the generated Go files. The sensors are printed to the terminal.

req := &emptypb.Empty{}
res, err := client.ListDevices(context.Background(), req)
if err != nil {
	log.Fatalf("could not write: %v", err)
}

The program then sends a request to the server that changes sampling frequency for specified sensor. This is done by sending predefined request with the protos function SetSamplingFrequency defined in the generated Go files. The request is created by providing the correct device name parameter, which is then sent to the server. The sampling frequency is printed to the terminal.

req := &protos.SetSamplingFrequencyRequest{
	DeviceName: "accelerometer",
	Frequency:  100,
}
_, err := client.SetSamplingFrequency(ctx, req)
if err != nil {
	log.Fatalf("could not set sampling frequency: %v", err)
}

The program then enters an infinite for loop that waits for a response from the server. The request is created by providing the correct device name and buffer length parameter, which is sent to the server.

req := &protos.ReadBufferedDataStreamRequest{
	DeviceName:   "accelerometer",
	FilterLength: 10,
}

stream, err := client.ReadBufferedDataStream(ctx, req)
if err != nil {
	log.Fatalf("failed to call ReadBufferedDataStream: %v", err)
}

fmt.Println("Starting to receive data from the buffered stream...")
for {
	res, err := stream.Recv()
	if err != nil {
		if err.Error() == "EOF" {
			fmt.Println("Stream ended.")
			break
		}
		log.Fatalf("error receiving data from stream: %v", err)
	}
	fmt.Printf("Received data: %v\n", res)
}

Each response is printed to the terminal and contains the following values:

{
  "raw": {
    "X": 1455,
    "Y": 244,
    "Z": 16232
  },
  "scaled": {
    "X": 0.8703883,
    "Y": 0.14596203,
    "Z": 9.710064
  },
  "max": {
    "raw": {
      "X": 1481,
      "Y": 280,
      "Z": 16250
    },
    "scaled": {
      "X": 0.8859416,
      "Y": 0.16749741,
      "Z": 9.720832
    }
  },
  "min": {
    "raw": {
      "X": 1418,
      "Y": 196,
      "Z": 16216
    },
    "scaled": {
      "X": 0.84825474,
      "Y": 0.117248185,
      "Z": 9.700493
    }
  },
  "timestamp": "2024-11-15T18:02:12Z"
}

1.2. Proto File

The Proto file used for the IMU service is the following:

/**
 * IMU Sensor Service.
 *
 * Service that enables a control of the Magnetometer and IMU sensors
 */
syntax = "proto3";

package hal.imu;

import "google/protobuf/empty.proto";

option go_package = "./protos;protos";

message Channels {
  string name = 1;
  float value = 2;
}

/**
 * Represents the ReadSampleRaw response data.
 */
message ReadSampleRawResponse {
  repeated Channels channels = 1;
}

/**
 * Represents the ReadSampleScaled response data.
 */
message ReadSampleScaledResponse {
  repeated Channels channels = 1;
}

/**
 * Represents the ReadBufferedDataStream request data.
 */
message ReadBufferedDataStreamRequest {
  string deviceName = 1;
  int32 filterLength = 2;
}

message RawDataStream {
  int32 X = 1;
  int32 Y = 2;
  int32 Z = 3;
}

message ScaledDataStream {
  float X = 1;
  float Y = 2;
  float Z = 3;
}

message MaxDataStream {
  RawDataStream raw = 1;
  ScaledDataStream scaled = 2;
}

message MinDataStream {
  RawDataStream raw = 1;
  ScaledDataStream scaled = 2;
}

/**
 * Represents the ReadBufferedDataStream response data.
 */
message ReadBufferedDataStreamResponse {
  RawDataStream raw = 1;
  ScaledDataStream scaled = 2;
  MaxDataStream max = 3;
  MinDataStream min = 4;
  string timestamp = 5;
}

/**
 * Represents the GetCurrentScale response data.
 */
message GetCurrentScaleResponse {
  float scale = 1;
}

/**
 * Represents the GetAvailableScales response data.
 */
message GetAvailableScalesResponse {
  repeated float scales = 1;
}

/**
 * Represents the SetScale request data.
 */
message SetScaleRequest {
  string deviceName = 1;
  float scale = 2;
}

/**
 * Represents the GetCurrentSamplingFrequency response data.
 */
message GetCurrentSamplingFrequencyResponse {
  float frequency = 1;
}

/**
 * Represents the GetAvailableSamplingFrequency response data.
 */
message GetAvailableSamplingFrequencyResponse {
  repeated float frequencies = 1;
}

/**
 * Represents the SetSamplingFrequency request data.
 */
message SetSamplingFrequencyRequest {
  string deviceName = 1;
  float frequency = 2;
}

message ChooseDeviceRequest {
  string deviceName = 1;
}

message ImuDeviceResponse {
  string name = 1;
}

/**
 * Represents the ListDevice response data.
 */
message ImuListDevicesResponse {
  repeated ImuDeviceResponse devices = 1;
}

/**
 * Service exposing IMU sensor functions.
 */
service Imu {
  /// Used to retrive raw samples from sensors
  rpc ReadSampleRaw(ChooseDeviceRequest) returns (ReadSampleRawResponse) {}

  /// Used to retrive scaled samples from sensors
  rpc ReadSampleScaled(ChooseDeviceRequest) returns (ReadSampleScaledResponse) {}

  /// Used to stream scaled and raw samples from sensors
  rpc ReadBufferedDataStream(ReadBufferedDataStreamRequest) returns (stream ReadBufferedDataStreamResponse) {}

  /// Used to retrieve current scales
  rpc GetCurrentScale(ChooseDeviceRequest) returns (GetCurrentScaleResponse) {}

  /// Used to retrieve available scales
  rpc GetAvailableScales(ChooseDeviceRequest) returns (GetAvailableScalesResponse) {}

  /// Used to write scale value
  rpc SetScale(SetScaleRequest) returns (google.protobuf.Empty) {}

  /// Used to retrieve current sampling frequency
  rpc GetCurrentSamplingFrequency(ChooseDeviceRequest) returns (GetCurrentSamplingFrequencyResponse) {}

  /// Used to retrieve available sampling frequency
  rpc GetAvailableSamplingFrequency(ChooseDeviceRequest) returns (GetAvailableSamplingFrequencyResponse) {}

  /// Used to write sampling frequency value
  rpc SetSamplingFrequency(SetSamplingFrequencyRequest) returns (google.protobuf.Empty) {}

  // Used to get all available devices
  rpc ListDevices(google.protobuf.Empty) returns (ImuListDevicesResponse) {}
}

To list all available IMU services, use grpcurl, which is an open-source utility for accessing gRPC services via the shell. For help setting up the grpcurl command, see gRPC Usage.

To list all available IMU devices, use the following line:

grpcurl -expand-headers -H 'Authorization: Bearer <token>' -emit-defaults -plaintext <device_ip>:<grpc_server_port> list hal.imu.Imu

The token field is the fetched TDC-X authorization token. For help fetching this token, refer to gRPC Usage. The device-ip:grpc_server_port is the TDC-X IP address and the gRPC serving port. For example, if the token value was token and the address and port were 192.168.0.100:8081, you would use the following line to list all available IMU services.

grpcurl -expand-headers -H 'Authorization: Bearer token' -emit-defaults -plaintext 192.168.0.100:8081 list hal.imu.Imu

The response will be in this format:

hal.imu.Imu.GetAvailableSamplingFrequency
hal.imu.Imu.GetAvailableScales
hal.imu.Imu.GetCurrentSamplingFrequency
hal.imu.Imu.GetCurrentScale
hal.imu.Imu.ListDevices
hal.imu.Imu.ReadBufferedDataStream
hal.imu.Imu.ReadSampleRaw
hal.imu.Imu.ReadSampleScaled
hal.imu.Imu.SetSamplingFrequency
hal.imu.Imu.SetScale

Additionally, you can use the gRPC Clicker VSCode extension for working with gRPC services. For help setting the service up, refer to gRPC Usage.

1.3. Application Deployment

1.3.1. Dockerfile

To deploy the application, a Go container should be created and deployed to the TDC-X. To that end, a Dockerfile is created. The file is shown below.

# build image for Go app
FROM golang:1.22.0 AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download
COPY . .

# setting environment
ENV GOOS=linux
ENV GOARCH=arm64

RUN go build -o imu-grpc ./cmd/main.go

# runtime image
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/imu-grpc .

CMD ["./imu-grpc"]

Open a terminal and paste the following commands:

docker build -t imu-grpc-app .
docker save -o imu-grpc-app.tar imu-grpc-app:latest

This will build the docker container and save the application as a .tar file which can be used for Portainer upload.

1.3.2. Dockerfile Breakdown

The Dockerfile first creates a build image that is used to build the Go application. It sets the working directory as /app, then copies the go.mod and go.sum files to the directory and downloads all needed files.

COPY go.mod go.sum ./
RUN go mod download
COPY . .

Next, the Go environment is set. This needs to be done as the TDC-X device has the arm64 architecture and is based on Linux. With this in mind, the application is set to the following:

ENV GOOS=linux
ENV GOARCH=arm64

The application is built as imu-grpc.

RUN go build -o imu-grpc ./cmd/main.go

Finally, a runtime image is created from the latest alpine version for a smaller image size. The working directory is once again set to /app, and the application is copied. The last line specifies that, upon deployment, the imu-grpc application is started.

FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/imu-grpc .

CMD ["./imu-grpc"]

1.3.3. Deploying to Portainer

To deploy the application to the TDC-X device, Portainer can be used. To see instructions on the process, refer to Working with Portainer. As soon as the image and container are set up, the application starts running.

Download the example code from the link below:

Download Example Code

2. Node-RED gRPC Example

This section describes usage of the IMU sensors on the TDC-X device. A Node-RED gRPC application was created to demonstrate listing all IMU sensor devices, reading data from said devices and setting parameters(ex. sampling frequency).

For implementation, the following nodes are used:

  • inject node
  • function node
  • gRPC call node
  • debug node.

The gRPC node set is not part of the initial Node-RED package list and will have to be installed to the Palette. For gRPC node installation, import the following file in the Manage Palette section: Download Node

The gRPC node server needs to be set properly to be able to connect to the gRPC server and interpret server results correctly. The following configuration is used:

  • Server:192.168.0.100
  • Port:8081
  • a Proto File is provided.

To test out any of the listed functionalities, use the inject node. The result should appear in the debug node.

See a screenshot of the application below:

IMU Node-RED Example

Download the example code from the link below:

Download Example Code

Temperature Sensor Examples

In this section, temperature sensors on the TDC-X device are discussed. Programming examples are given and thoroughly explained.

1. Go gRPC Example

In this section, a Go gRPC application is created and documented. A gRPC client is created from a Proto file to match the gRPC server the TDC-X device is serving. An application that lists all temperature sensors and prints their current value is created.

The application uses Golang's gRPC service to fetch and send data to the server.

1.1. Application Implementation

To implement a gRPC client, a Proto file matching the gRPC server's specifications is needed. This Proto file is then placed in a separate package, and from it, gRPC Go files are generated using the following commands:

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

export PATH="$PATH:$(go env GOPATH)/bin"

protoc --go_out=pkg/pb --go-grpc_out=pkg/pb pkg/temperature-sensor-service.proto

This installs needed Protoc files, exports the path and generates files for the gRPC service. The application also requires an access token to connect to the gRPC service the TDC-X provides. Please refer to gRPC Usage for instructions on how to generate an access token for the TDC-X.

Once you've obtained the token, navigate to grpc-temperature/pkg/auth/token.json. Paste your generated token in the access_token field. This token is then used to create a context which will be used to create gRPC calls.

The main application first creates a new gRPC client that connects to the TDC-X IP address on port 8081. It does so by using the generated gRPC Go file specification.

conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		log.Fatalf("did not connect: %v", err)
	}
	defer conn.Close()

To demonstrate the listed functionalities, the application lists all available temperature sensors on your TDC-X device, and then the temperature value of each specific sensor measured in °C is printed.

To list all temperature devices, an empty pb request is sent to the server. The client specifies the function ListDevices to receive all devices from the TDC-X device, which are then printed to the user.

req := &emptypb.Empty{}
res, err := client.ListDevices(context.Background(), req)

Afterwards, each sensor's temperature is read.

func readTemperature(ctx context.Context, client protos.TemperatureSensorClient, device string) {
	req := pb.TemperatureSensorReadRequest{
		Name: device,
	}
	res, err := client.Read(ctx, &req)
	if err != nil {
		log.Fatalf("couldn't read from temperature sensor: %v", err)
	}
	fmt.Printf("%s temperature: %.2f %s\n", device, res.Value, res.Unit)
}

This function creates a proto request for reading the temperature sensor from the given device name and prints the float result in °C.

1.2. Proto File

The Proto file used for the Temperature sensor service is the following:

/**
 * Temperature sensor service.
 *
 * Service that provides device temperature sensor reading
 */
syntax = "proto3";

package hal.temperaturesensor;

import "google/protobuf/empty.proto";

option go_package = "./protos;protos";


message TemperatureSensorDevice {
  string name = 1;
}

/**
 * Represents the ListDevice response data.
 */
message TemperatureSensorListDeviceResponse {
  repeated TemperatureSensorDevice devices = 1;
}

message TemperatureSensorReadRequest {
  string name = 1;
}

message TemperatureSensorReadResponse {
  float value = 1;
  string unit = 2;
}

/**
 * Service exposing TemperatureSensor functions.
 */
service TemperatureSensor {
  /// Used to retrieve all available temperature sensors
  rpc ListDevices(google.protobuf.Empty) returns (TemperatureSensorListDeviceResponse) {}

  /// Used to read value of a particular temperature sensor.
  rpc Read(TemperatureSensorReadRequest) returns (TemperatureSensorReadResponse) {}
}

To list all available temperature sensor services, use grpcurl, which is an open-source utility for accessing gRPC services via the shell. For help setting up the grpcurl command, refer to gRPC Usage.

Use the following line for listing them:

grpcurl -expand-headers -H 'Authorization: Bearer <token>' -emit-defaults -plaintext <device_ip>:<grpc_server_port> list hal.temperaturesensor.TemperatureSensor

The token field is the fetched TDC-X authorization token. For help fetching this token, see gRPC Usage. The device-ip:grpc_server_port is the TDC-X IP address and the gRPC serving port. For example, if the token value was token and the address and port were 192.168.0.100:8081, you would use the following line to list all available temperature sensor devices.

grpcurl -expand-headers -H 'Authorization: Bearer token' -emit-defaults -plaintext 192.168.0.100:8081 list hal.temperaturesensor.TemperatureSensor

The response should be in this format:

hal.temperaturesensor.TemperatureSensor.ListDevices
hal.temperaturesensor.TemperatureSensor.Read

Additionally, you can use the gRPC Clicker VSCode extension for working with gRPC services. For help setting the service up, refer to gRPC Usage.

1.3. Application Deployment

1.2.1. Dockerfile

To deploy the application, a Go container should be created and deployed to the TDC-X. To that end, a Dockerfile is created. The file is shown below.

# build image for Go app
FROM golang:1.22.0 AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download
COPY . .

# setting environment
ENV GOOS=linux
ENV GOARCH=arm64

RUN go build -o temp-grpc ./cmd/main.go

# runtime image
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/temp-grpc .

CMD ["./temp-grpc"]

Open a terminal and paste the following commands:

docker build -t temp-grpc-app .
docker save -o temp-grpc-app.tar temp-grpc-app:latest

This will build the docker container and save the application as a .tar file which can be used for Portainer upload.

1.2.2. Dockerfile Breakdown

The Dockerfile first creates a build image that is used to build the Go application. It sets the working directory as /app, then copies the go.mod and go.sum files to the directory and downloads all needed files.

COPY go.mod go.sum ./
RUN go mod download
COPY . .

Next, the Go environment is set. This needs to be done as the TDC-X device has the arm64 architecture and is based on Linux. With this in mind, the application is set to the following:

ENV GOOS=linux
ENV GOARCH=arm64

The application is built as temp-grpc.

RUN go build -o temp-grpc ./cmd/main.go

Finally, a runtime image is created from the latest alpine version for a smaller image size. The working directory is once again set to /app, and the application is copied. The last line specifies that, upon deployment, the temp-grpc application is started.

FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/temp-grpc .

CMD ["./temp-grpc"]

1.2.3. Deploying to Portainer

To deploy the application to the TDC-X device, Portainer can be used. To see instructions on the process, refer to Working with Portainer. As soon as the image and container are set up, the application starts running.

Download the example code from the link below:

Download Example Code

2. Node-RED gRPC Example

This section describes usage of the temperature sensors on the TDC-X device. A Node-RED gRPC application was created to demonstrate listing all temperature sensor devices and reading temperature values from said devices.

For implementation, the following nodes are used:

  • inject node
  • gRPC call node
  • debug node.

The gRPC node set is not part of the initial Node-RED package list and will have to be installed to the Palette. For gRPC node installation, import the following file in the Manage Palette section: Download Node

The gRPC node server needs to be set properly to be able to connect to the gRPC server and interpret server results correctly. The following configuration is used:

  • Server:192.168.0.100
  • Port:8081
  • a Proto File is provided.

To test out any of the listed functionalities, use the inject node. The result should appear in the debug node.

See a screenshot of the application below:

AIN Node-RED Example

Download the example code from the link below:

Download Example Code

3. Lua Example

A Lua application is provided as a temperature sensor usage example. The example demonstrates listing all temperature sensor devices and reading their temperature value. This is done by creating monitors which read the temperature value of devices. See an example below.

PCB0 = Monitor.Temperature.create('PCB_TEMP0')
print(string.format("PCB_TEMP0: %.2f°C", extractTemperature(PCB0)))

The extractTemperature function checks whether the passed variable is a monitor. If not, the function returns the nil value. Otherwise, it uses the monitor's get() function to fetch the temperature value of the device.

local function extractTemperature(temperatureMonitor)
  if not temperatureMonitor then
    return nil
  end
  return temperatureMonitor:get()
end

See the result of the application run below.

[13:34:00.046: INFO: AppEngine] Starting app: 'temperaturesensor' (priority: LOW)
PCB_TEMP0: 44.00°C
PCB_TEMP1: 41.00°C
A53_CPU_TEMP: 61.00°C
SOC_ANAMIX_TEMP: 61.00°C

Download the example code from the link below:

Download Example Code

Controller Area Network Examples

In this section, the Controller Area Network (CAN) interface of the TDC-X device and examples of its usage are discussed. Programming examples are given and thoroughly explained.

1. Setting up CAN Device

In this section, setting up the CAN device is discussed. Using the dedicated CAN HAL service, setup of the following parameters is possible:

  • Transceiver Power
  • Termination
  • Interface Mapping (namespace)

In the following sections, setting up the transceiver power, termination, and CAN namespace is discussed. Additionally, reading CAN statistics is described. This is done using the CAN HAL Service hal.can.Can. Examples using grpcurl are given below. For more information about using gRPC services, refer to gRPC Usage.

1.1. Setting Up CAN Transceiver Power

The CAN HAL service provides a means to set up transceiver power for the CAN interface. The HAL service hal.can.Can.SetTransceiverPower is used. An example of setting the transceiver power of the connected CAN device on is given below.

grpcurl -d '{"interfaceName":"CAN1","powerOn":true}' -H 'Authorization: Bearer token' -plaintext 192.168.0.100:8081 hal.can.Can/SetTransceiverPower
{}

Checking changes to the CAN state is done by using the hal.can.Can.GetTransceiverPower HAL service.

grpcurl -d '{"interfaceName":"CAN1"}' -H 'Authorization: Bearer token' -plaintext 192.168.0.100:8081 hal.can.Can/GetTransceiverPower
{
  "powerOn": true
}

1.2. Setting Up CAN Termination

The CAN HAL service provides a means to set up CAN termination. The HAL service hal.can.Can.SetTermination is used. An example of setting the termination of the connected CAN device on is given below.

grpcurl -d '{"interfaceName":"CAN1","enableTermination":true}' -H 'Authorization: Bearer token' -plaintext 192.168.0.100:8081 hal.can.Can/SetTermination
{}

Checking changes to the termination is done by using the hal.can.Can.GetTermination HAL service.

grpcurl -d '{"interfaceName":"CAN1"}' -H 'Authorization: Bearer token' -plaintext 192.168.0.100:8081 hal.can.Can/GetTermination
{
  "terminationEnabled": true
}

1.3. Setting Up CAN Interface Mapping

When working with the CAN interface, one can use different namespaces. The CAN interface is then bound to a namespace and can be accessed from there. Examples include:

  • AppEngine
  • Host
  • Any other running container

To set the namespace of the CAN interface, the HAL service hal.can.Can.SetInterfaceToContainer is used. Possible values for the dockerContainerName parameter include:

  • app-engine - maps the CAN interface to AppEngine
  • empty string - maps the CAN interface to the host
  • container-name - maps the CAN interface to a running Docker container

See an example of exposing the CAN interface to the host below.

grpcurl -d '{"interfaceName":"CAN1","dockerContainerName":""}' -H 'Authorization: Bearer token' -plaintext 192.168.0.100:8081 hal.can.Can/SetInterfaceToContainer
{}

To see the changes to the interface mapping, the hal.can.Can.GetInterfaceTocontainerMapping service is used.

grpcurl -d '{}' -H 'Authorization: Bearer token' -plaintext 192.168.0.100:8081 hal.can.Can/GetInterfaceToContainerMapping
{
  "items": [
    {
      "interfaceName": "CAN1",
      "dockerContainerName": ""
    }
  ]
}

NOTE: Mapping the CAN interface results in exclusive access to the interface.

Mapping the CAN interface to AppEngine will render access to the interface exclusive to AppEngine. Example usage can be see in the Lua Example below. Mapping the interface to the host, the CAN interface is visible only to the host. Example usage can be seen in the Go Example below.

1.4. Viewing CAN Statistics

To see statistics of your CAN device, the HAL service hal.can.Can.GetStatistics is used. See an example of its usage below.

grpcurl -d '{"interfaceName":"CAN1"}' -H 'Authorization: Bearer token' -plaintext 192.168.0.100:8081 hal.can.Can/GetStatistics
{
  "RxPackets": "1118",
  "TxPackets": "92",
  "RxBytes": "4783",
  "TxBytes": "736",
  "RxErrors": "0",
  "TxErrors": "0",
  "RxDropped": "0",
  "TxDropped": "1",
  "Multicast": "0",
  "Collisions": "0",
  "RxLengthErrors": "0",
  "RxOverErrors": "0",
  "RxCrcErrors": "0",
  "RxFrameErrors": "0",
  "RxFifoErrors": "0",
  "RxMissedErrors": "0",
  "TxAbortedErrors": "1",
  "TxCarrierErrors": "0",
  "TxFifoErrors": "0",
  "TxHeartbeatErrors": "0",
  "TxWindowErrors": "0",
  "RxCompressed": "0",
  "TxCompressed": "0"
}

2. Go Example

A Go application is provided as a CAN usage example. The application creates a CAN bus, binds it to a CAN port, and sends and receives CAN data simultaneously. For application testing, the Kvaser Leaf Light v2 device was used. For generating and testing data, Kvaser's CanKing application and drivers were used.

2.1. Application Implementation

The application is implemented using the canbus Go package to communicate with the CAN bus.

NOTE: Check your CAN usage namespace, and check if the CAN link is up. Check the previous section to see CAN namespace assignment. If the CAN link is already enabled, skip the next step, and run the application normally.

If the CAN link is down, whilst running the application, add a --setup=true flag to the starting arguments, which will set up the CAN interface according to specifications in the /pkg/canSetup file. The setup sets the CAN bitrate, powers on the transceiver and brings the interface link up.

./can --setup=true

Otherwise, run the application normally. Firstly, a new CAN bus connection is established using the following lines:

can, err := canbus.New()
if err != nil {
	log.Fatalf("Failed to open CAN socket: %v", err)
}
defer can.Close()

The bus is then bound to the can0 interface.

err = can.Bind("can0")
if err != nil {
	log.Fatalf("Cannot bind to can socket: %s", err)
}

Two goroutines are started simultaneously. One sends sample data from the CAN device, while the other listens to received data and prints the data to the console. The goroutines are synced using a wait group that waits for all routines to finish execution. See both goroutines explained below.

The goroutine code snippet for sending data is shown below.

data := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
frame := canbus.Frame{
	ID:   0x123,
	Data: data,
}

for {
	if _, err := can.Send(frame); err != nil {
		log.Printf("Failed to send CAN frame: %v", err)
	} else {
		fmt.Println("Sent CAN frame:", frame)
	}

	time.Sleep(1 * time.Second)
}

The specified code creates a data structure which contains the ID and data to be sent by a message, and attempts to send the CAN frame to the CAN device each second.

See how the goroutine receives CAN data below. A frame variable is used which listens to the CAN device, and the device logs all received data as it's sent.

frame, err := can.Recv()
if err != nil {
  log.Printf("Failed to receive CAN frame: %v", err)
} else {
	log.Println("Received CAN frame:", frame)
}

See a screenshot of sending and receiving CAN data below.

Sending Data from CAN

An example print from the application can be seen below.

Received CAN frame: {1122 [220 3 0 0 0] SFF}
Received CAN frame: {586 [221 3 0] SFF}
Received CAN frame: {1792 [222 3 0 0 0 0 0 0] SFF}
Received CAN frame: {538 [223 3 0 0] SFF}
Received CAN frame: {511 [225] SFF}
Received CAN frame: {931 [226 3 0 0] SFF}
Received CAN frame: {1904 [227 3 0 0 0 0] SFF}
Received CAN frame: {653 [] SFF}
Received CAN frame: {1520 [229] SFF}
Received CAN frame: {1565 [230 3 0 0 0 0 0] SFF}
Received CAN frame: {854 [231 3 0 0 0] SFF}
Sent CAN frame: {291 [1 2 3 4 5 6 7 8] SFF}
Sent CAN frame: {291 [1 2 3 4 5 6 7 8] SFF}
Sent CAN frame: {291 [1 2 3 4 5 6 7 8] SFF}

2.2. Application Deployment

2.2.1. Dockerfile

To deploy the application, a Go container should be created and deployed to the TDC-X. To that end, a Dockerfile is created. The file is shown below.

# build image for Go app
FROM golang:1.22.0 AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download
COPY . .

# setting environment
ENV GOOS=linux
ENV GOARCH=arm64

RUN go build -o can ./cmd/main.go

# runtime image
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/can .

CMD ["./can"]

Open a terminal and paste the following commands:

docker build -t can-app .
docker save -o can-app.tar can-app:latest

This will build the docker container and save the application as a .tar file which can be used for Portainer upload.

2.2.2. Dockerfile Breakdown

The Dockerfile first creates a build image that is used to build the Go application. It sets the working directory as /app, then copies the go.mod and go.sum files to the directory and downloads all needed files.

COPY go.mod go.sum ./
RUN go mod download
COPY . .

Next, the Go environment is set. This needs to be done as the TDC-X device has the arm64 architecture and is based on Linux. With this in mind, the application is set to the following:

ENV GOOS=linux
ENV GOARCH=arm64

The application is built as can.

RUN go build -o can ./cmd/main.go

Finally, a runtime image is created from the latest alpine version for a smaller image size. The working directory is once again set to /app, and the application is copied. The last line specifies that, upon deployment, the can application is started.

FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/can .

CMD ["./can"]

2.2.3. Deploying to Portainer

To deploy the application to the TDC-X device, Portainer can be used. To see instructions on the process, refer to Working with Portainer. As soon as the image and container are set up, the application starts running.

Download the example code from the link below:

Download Example Code

3. Lua Example

A Lua application is provided as a CAN usage example. The application creates a CAN handler, opens the handler and sends or receives CAN data. For application testing, the Kvaser Leaf Light v2 device was used. For generating and testing data, Kvaser's CanKing application and drivers were used.

The application first initializes the CAN socket. The CAN1 handle is created, and the baud rate is set to 500000. Termination is set to true, and the handle is opened.

Handle = CANSocket.create('CAN1')
Handle:setBaudRate(500000)
Handle:setTermination(true)
Handle:open()

For printing received data and logging errors, two local functions were implemented.

--@handleOnReceive(id:int,buffer:binary)
local function handleOnReceive(id, buffer)
  print("Received data.")
  print(id)
end

--@handleOnReceive(errorCode:int)
local function handleOnError(errorCode)
  Log.info("Error code " .. errorCode)
end

CANSocket.register(Handle, "OnReceive", handleOnReceive)
CANSocket.register(Handle, "OnError", handleOnError)

Reading is tested with the CanKing application. See a screenshot of sending CAN data below.

Sending Data from CAN

To send data to the device, three test IDs and messages are created. The script then sends the defined data in an infinite loop.

Ids = {20, 21, 22}
Msgs = {"\x41\x42\x43\x44\x45\x46\x47\x48", "\x51\x52\x53\x54\x55\x56\x57\x58", "\x61\x62\x63\x64\x65\x66\x67\x68"}
while true do
  Handle:transmit(Ids, Msgs)
  Script.sleep(1000)
end

See the result of the application run below.

[12:52:10.131: INFO: AppEngine] Starting app: 'can' (priority: LOW)
Received data.
776
Received data.
779
Received data.
693
Received data.
857

Download the example code from the link below:

Download Example Code

GNSS Examples

In this section, GNSS on the TDC-X device is discussed. Programming examples are given and thoroughly explained.

1. Go gRPC Example

In this section, a Go gRPC application is created and documented. A gRPC client is created from a Proto file to match the gRPC server the TDC-X device is serving. An application that checks device availability and fetches GNSS data is provided.

The application uses Golang's gRPC service to fetch and send data to the server.

1.1. Application Implementation

To implement a gRPC client, a Proto file matching the gRPC server's specifications is needed. This Proto file is then placed in a separate package, and from it, gRPC Go files are generated using the following commands:

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

export PATH="$PATH:$(go env GOPATH)/bin"

protoc --go_out=pkg/pb --go-grpc_out=pkg/pb pkg/gnss.proto

This installs needed Protoc files, exports the path and generates files for the gRPC service. The application also requires an access token to connect to the gRPC service the TDC-X provides. Please refer to gRPC Usage for instructions on how to generate an access token for the TDC-X.

Once you've obtained the token, navigate to grpc-gnss/pkg/auth/token.json. Paste your generated token in the access_token field. This token is then used to create a context which will be used to create gRPC calls.

The main application first creates a new gRPC client that connects to the TDC-X IP address on port 8081. It does so by using the generated gRPC Go file specification.

conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		log.Fatalf("did not connect: %v", err)
	}
	defer conn.Close()

The application first checks the GNSS device status by calling the HAL service for getting the device status. If the transceiver isn't set up, it sends a request to enable the receiver and antenna of the device.

func setUpGnss(client protos.GnssClient, ctx context.Context) {
	req := &protos.DeviceManagementRequest{
		EnableReciever: true,
		EnableAntenna:  true,
	}
	_, err := client.SetDeviceStatus(ctx, req)
	if err != nil {
		log.Fatalf("could not set status: %v", err)
	}
}

The application then starts streaming GNSS data. This includes a timestamp, the position of the device, device speed, course, the number of satellites, fix type, fix quality and HDOP. An example log of the application is provided below:

2023/09/20 13:01:52 Timestamp: 2024-12-19T11:13:53Z
2023/09/20 13:01:52 Latitude: 46.286890516666666
2023/09/20 13:01:52 Longitude: 16.321311299999998
2023/09/20 13:01:52 Altitude: 183.8
2023/09/20 13:01:52 Speed (Kph): 0
2023/09/20 13:01:52 Speed (Mph): 0
2023/09/20 13:01:52 Course: 0
2023/09/20 13:01:52 Satellites: 10
2023/09/20 13:01:52 Fix Type: 3
2023/09/20 13:01:52 Fix Quality: 1
2023/09/20 13:01:52 HDOP: 0.8

To stream the GNSS data, the application sends a HAL Service request to the TDC device. A stream is opened, which lists GNSS messages indefinitely. This is shown in the code below:

func streamGnssJson(client protos.GnssClient, ctx context.Context) {
	stream, err := client.StreamDataJson(ctx, &empty.Empty{})
	if err != nil {
		log.Fatalf("Error while calling StreamDataJson: %v", err)
	}
	log.Printf("--------------------------------")
	for {
		data, err := stream.Recv()
		if err != nil {
			log.Fatalf("Error while receiving data: %v", err)
		}

		log.Printf("Timestamp: %v", data.Timestamp)
		log.Printf("Latitude: %v", data.Latitude)
		log.Printf("Longitude: %v", data.Longitude)
		log.Printf("Altitude: %v", data.Altitude)
		log.Printf("Speed (Kph): %v", data.SpeedKph)
		log.Printf("Speed (Mph): %v", data.SpeedMph)
		log.Printf("Course: %v", data.Course)
		log.Printf("Satellites: %v", data.Satellites)
		log.Printf("Fix Type: %v", data.FixType)
		log.Printf("Fix Quality: %v", data.FixQuality)
		log.Printf("HDOP: %v", data.Hdop)
		log.Printf("--------------------------------")

		time.Sleep(1 * time.Second)
	}
}

1.2. Proto File

The Proto file used for the GNSS service is the following:

/**
 * GNSS Service.
 *
 * Service for reading GNSS data
 */
 syntax = "proto3";

 package hal.gnss;
 
 import "google/protobuf/empty.proto";
 
 option go_package = "./protos;protos";

enum NMEAFrequency {
    _1Hz = 0;
    _2Hz = 1;
    _5Hz = 2;
    _10Hz = 3;
}

message NMEAFrequenciesList {
    repeated string frequencies = 1;
}

message NMEAFrequencySettings {
    NMEAFrequency frequency = 1;
}

message GnssDataJson {
    double latitude = 1;     // The latitude of the GNSS position in decimal degrees.
    double longitude = 2;    // The longitude of the GNSS position in decimal degrees.
    double altitude = 3;     // The altitude of the GNSS position in meters.
    string timestamp = 4;    // The timestamp of the GNSS record in ISO 8601 format.
    double speed_kph = 5;    // The speed of the object in kilometers per hour.
    double speed_mph = 6;    // The speed of the object in miles per hour.
    double course = 7;       // The direction of travel in degrees, where 0 is north.
    double satellites = 8;   // The number of satellites used to determine the position.
    double fix_type = 9;     // Indicates the type of GNSS fix (e.g., 1 for no fix, 2 for 2D fix, 3 for 3D fix).
    double fix_quality = 10;  // Indicates the GNSS fix quality (e.g., 0 for invalid, 1 for GPS fix, 2 for DGPS fix).
    double hdop = 11;        // The Horizontal Dilution of Precision, a measure of the GNSS signal's accuracy.
}

message GnssData{
    string sentence = 1;
}

message DeviceManagementRequest {
    bool enableReciever = 1;
    bool enableAntenna = 2;
}

message DeviceManagementResponse {
    bool enableReciever = 1;
    bool enableAntenna = 2;
    bool sessionActive = 3;
    bool rebootRequired = 4;
}

message GnssConstellationsSettings {
    bool GLONASS = 1;
    bool BDS = 2;
    bool Galileo = 3;
}

message GnssConstellations {
    repeated string constellations = 1;
}


 /**
  * Service exposing GNSS functions.
  */
  service Gnss {
    // Provides parsed Gnss data as JSON
    rpc StreamDataJson(google.protobuf.Empty) returns (stream GnssDataJson) {}
  
    // Provides raw stream that is returned by the Gnss receiver
    rpc StreamDataRaw(google.protobuf.Empty) returns (stream GnssData) {}

    // Provides a way do get receiver and antenna status
    rpc GetDeviceStatus(google.protobuf.Empty) returns (DeviceManagementResponse) {}
  
    // Provides a way do disable/enable receiver and antenna
    rpc SetDeviceStatus(DeviceManagementRequest) returns (google.protobuf.Empty) {}
  
    // Provides a way to set preferred constellations
    rpc SetConstellations(GnssConstellationsSettings) returns (google.protobuf.Empty) {}
  
    // Provides a way to get current preferred constellations
    rpc GetConstellations(google.protobuf.Empty) returns (GnssConstellationsSettings) {}
  
    // Provides a way to get a list of available constellations
    rpc ListAvailableConstellations(google.protobuf.Empty) returns (GnssConstellations) {}
  
    // Provides a way to set NMEA output frequency
    rpc SetNmeaOutputFrequency(NMEAFrequencySettings) returns (google.protobuf.Empty) {}
  
    // Provides a way to set NMEA output frequency
    rpc GetNmeaOutputFrequency(google.protobuf.Empty) returns (NMEAFrequencySettings) {}
  
    // Provides a way to get a list of available NMEA output frequencies
    rpc ListNmeaOutputFrequencies(google.protobuf.Empty) returns (NMEAFrequenciesList) {}
  }

To get the GNSS device status, use grpcurl, which is an open-source utility for accessing gRPC services via the shell. For help setting up the grpcurl command, refer to gRPC Usage.

Use the following line:

grpcurl -expand-headers -H 'Authorization: Bearer <token>' -emit-defaults -plaintext <device_ip>:<grpc_server_port> hal.gnss.Gnss.GetDeviceStatus

The token field is the fetched TDC-X authorization token. For help fetching this token, see gRPC Usage. The device-ip:grpc_server_port is the TDC-X IP address and the gRPC serving port. For example, if the token value was token and the address and port were 192.168.0.100:8081, you would use the following line to see the device status of the GNSS service.

grpcurl -expand-headers -H 'Authorization: Bearer token' -emit-defaults -plaintext 192.168.0.100:8081 hal.gnss.Gnss.GetDeviceStatus

The response should be in this format:

"enableReciever": true,
"enableAntenna": true,
"sessionActive": true,
"rebootRequired": false

Additionally, you can use the gRPC Clicker VSCode extension for working with gRPC services. For help setting the service up, refer to gRPC Usage.

1.3. Application Deployment

1.2.1. Dockerfile

To deploy the application, a Go container should be created and deployed to the TDC-X. To that end, a Dockerfile is created. The file is shown below.

# build image for Go app
FROM golang:1.22.0 AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download
COPY . .

# setting environment
ENV GOOS=linux
ENV GOARCH=arm64

RUN go build -o gnss-grpc ./cmd/main.go

# runtime image
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/gnss-grpc .

CMD ["./gnss-grpc"]

Open a terminal and paste the following commands:

docker build -t gnss-grpc-app .
docker save -o gnss-grpc-app.tar gnss-grpc-app:latest

This will build the docker container and save the application as a .tar file which can be used for Portainer upload.

1.2.2. Dockerfile Breakdown

The Dockerfile first creates a build image that is used to build the Go application. It sets the working directory as /app, then copies the go.mod and go.sum files to the directory and downloads all needed files.

COPY go.mod go.sum ./
RUN go mod download
COPY . .

Next, the Go environment is set. This needs to be done as the TDC-X device has the arm64 architecture and is based on Linux. With this in mind, the application is set to the following:

ENV GOOS=linux
ENV GOARCH=arm64

The application is built as gnss-grpc.

RUN go build -o gnss-grpc ./cmd/main.go

Finally, a runtime image is created from the latest alpine version for a smaller image size. The working directory is once again set to /app, and the application is copied. The last line specifies that, upon deployment, the gnss-grpc application is started.

FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/gnss-grpc .

CMD ["./gnss-grpc"]

1.2.3. Deploying to Portainer

To deploy the application to the TDC-X device, Portainer can be used. To see instructions on the process, refer to Working with Portainer. As soon as the image and container are set up, the application starts running.

Download the example code from the link below:

Download Example Code

2. Node-RED gRPC Example

This section describes usage of the the GNSS device on the TDC-X. A Node-RED gRPC application was created to demonstrate reading from the GNSS device.

For implementation, the following nodes are used:

  • inject node
  • gRPC call node
  • debug node.

The gRPC node set is not part of the initial Node-RED package list and will have to be installed to the Palette. For gRPC node installation, import the following file in the Manage Palette section: Download Node

The gRPC node server needs to be set properly to be able to connect to the gRPC server and interpret server results correctly. The following configuration is used:

  • Server:192.168.0.100
  • Port:8081
  • a Proto File is provided.

To test out any of the listed functionalities, use the inject node. The result should appear in the debug node.

See a screenshot of the application below:

GNSS Node-RED Example

Download the example code from the link below:

Download Example Code

3. Lua Example

A Lua application is provided as a GNSS usage example. The script first creates a GNSS handle and enables the GNSS service. The handle is used to fetch all needed data from the receiver.

gnssHandle = Gnss.create()
gnssHandle:enable()

The following metrics are fetched:

  • position,
  • signal metrics,
  • NMEA sentences,
  • speed, and
  • course.

It also provides a timestamp of the precise time when the application fetches the data. To get the position of your device, which includes latitude, longitude and altitude, the following code is used:

local x,y,z = gnssHandle:getPosition()
print("Latitude: " .. x)
print("Longitude: " .. y)
print("Altitude: " .. z)

To get signal metrics, the following code is used:

local q,w,e,r = gnssHandle:getSignalMetrics()
print("Number of satellites: " .. q)
print("Fix type: " .. w)
print("Fix quality: " .. e)
print("HDOP: " .. r)

This includes the number of satellites, the type of the fix used, fix quality and HDOP.

NMEA sentences are fetched using the code below:

local h=gnssHandle:getNmeaSentence('RMC')
print('RMC sentence: ' .. h)
h=gnssHandle:getNmeaSentence('GSA')
print('GSA sentence: ' .. h)
h=gnssHandle:getNmeaSentence('GGA')
print('GGA sentence: ' .. h)
h=gnssHandle:getNmeaSentence('GSV')
print('GSV sentence: ' .. h)
h=gnssHandle:getNmeaSentence('VTG')
print('VTG sentence: ' .. h)

In the end, speed and course is fetched:

print("Speed in KPH: " .. gnssHandle:getSpeed('KPH'))
print("Speed MPH:" .. gnssHandle:getSpeed('MPH'))
print("Course " .. gnssHandle:getCourse())

See the result of the application run below.

[12:08:27.359: INFO: AppEngine] Starting app: 'gnss' (priority: LOW)
GNSS Receiver is enabled
-------------------------------
Timestamp: 2024-12-19T10:21:54Z
Latitude: 46.28689185
Longitude: 16.321358916667
Altitude: 181.2
Speed [KPH]: 0.0
Speed [MPH]: 0.0
Course: 36.3
Satellite number: 9.0
Fix type: 3.0
Fix quality: 1.0
HDOP: 0.9
-------------------------------

Download the example code from the link below:

Download Example Code

Networking Examples

1. Lua Examples

In this section Lua code examples will be shown.

1.1. Control Center Admin Token

To get a required administrator token, see the snippet bellow how to request it through the TDC login interface.

-- Define endpoint.
local endpoint = "http://192.168.0.100" .. "/auth/login"
-- Create client.
local client = HTTPClient.create()
-- Create request.
local request = HTTPClient.Request.create()
request:setURL(endpoint)
request:setMethod("POST")
-- Prepare credentials.
request:addHeader("Accept", "application/json")
request:setContentType("application/json")
request:setContentBuffer('{"username":"admin","password":"myadminpassword","realm":"admin"}')
-- Execute request.
local response = client:execute(request)
local response_code = response:getStatusCode()
local response_body = response:getContent()
-- Extract token
local json = JSON.create()
local json_body = json:parseFromString(response_body) 
-- Token   
local login_token = json_body:getValue("/token")   

A JWT token is returned that needs to be included in every API request.

  • Example:
eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhZGRyIjoiMTcyLjE4LjAuMiIsImVtYWlsIjoiYWRtaW5Ac2ljay5jb20iLCJleHAiOjE3MzY3NzQ5OTQsImlhdCI6MTczNjc3MTM5NCwiaXNzIjoiaHR0cDovLzE5Mi4xNjguMC4xMDAvYXV0aC9sb2dpbiIsImp0aSI6IjFab0dsR0xTOFJvQ3kwVEJkUHhhNFZ5V1RkeTlIWVpUQUJ3dnBEazciLCJuYmYiOjE3MzY3NzXjnxjdnsajkfbajiI6ImFkbWluIiwicmVhbG0iOiJhZG1pbiIsInJvbGVzIjpbImF1dGhwL3VzZXIiLCJjY2F1dGgvYWRtaW4iXSwic3ViIjoiYWRtaW4ifQ.pNikIbs9JRzkaRI3gU9GXqyXYn2Z-de9Na22bi8NJe7sGo-pzUTV61RipNWEnYOxn66x-snzie_Wy_17fdxOtg

Download Example Code

Note: Please download required helper script: Util script.

1.2. Interface List

Get a list of all network interfaces.

The list is returned as a JSON array containing the name and all interface properties.

-- Define endpoint.
local endpoint = "http://192.168.0.100" .. "/api/v1/network/interfaces/"
-- Create client.
local client = HTTPClient.create()
-- Create request.
local request = HTTPClient.Request.create()
request:setURL(endpoint)
request:setMethod("GET")
-- Prepare credentials.
request:addHeader("accept", "application/json")
request:addHeader("Authorization", "Bearer " .. "TOKEN_HASH")
-- Execute.
local response = client:execute(request)
-- Get status code & json response.
local response_code = response:getStatusCode()
local response_body = response:getContent()
  • Response
[
  {
    "isConfigurable": true,
    "name": "eth1",
    "settings": {
      "defaultGateway": "192.168.0.1",
      "dhcpFallbackAddress": "",
      "dhcpFallbackGateway": "",
      "dhcpFallbackMode": 0,
      "enabled": true,
      "mac": "f8:dc:7a:b0:05:1e",
      "staticAddress": "192.168.0.100/24",
      "useDhcp": false
    },
    "status": {
      "address": "192.168.0.100/24",
      "defaultGateway": "192.168.0.1",
      "enabled": true,
      "state": 6
    },
    "type": 1
  }
]

Download Example Code

1.3. Interface Settings

1.3.1. Get Network Interface Settings

Get the settings of an interface by name.

http://192.168.0.100/api/v1/network/interfaces/{name}/settings
-- Define endpoint
local endpoint = "http://192.168.0.100" .. "/api/v1/network/interfaces/eth1/settings"
-- Create client.
local client = HTTPClient.create()
-- Create request.
local request = HTTPClient.Request.create()
request:setURL(endpoint)
request:setMethod("GET")
-- Prepare credentials.
request:addHeader("accept", "application/json")
request:addHeader("Authorization", "Bearer " .. "TOKEN_HASH")

-- Execute.
local response = client:execute(request)  
-- Get status code & json response.
local response_code = response:getStatusCode()
local response_body = response:getContent()
  • Response
{
  "defaultGateway": "192.168.0.1",
  "dhcpFallbackAddress": "",
  "dhcpFallbackGateway": "",
  "dhcpFallbackMode": 0,
  "enabled": true,
  "mac": "f8:dc:7a:b0:05:1e",
  "staticAddress": "192.168.0.100/24",
  "useDhcp": false
}

Download Example Code

1.4. Set Network Interface Mode

Set DHCP or static mode for a network interface.

When settings are accepted and applied, status code 204 is returned.

Note: Value of all true or false type variables must be boolean.

1.4.1. Set DHCP Mode

To set a DHCP mode for an interface, JSON with settings must be sent using the PUT HTTP method.

Note: When setting DHCP mode, a static fallback address must be set.

  • JSON data
{
  "enabled":true, 
  "useDhcp":true, 
  "staticAddress":"", 
  "defaultGateway": "", 
  "dhcpFallbackAddress":"192.168.2.1/24", 
  "dhcpFallbackGateway":"0.0.0.0", 
  "dhcpFallbackMode":0
}
  • LUA example code
-- Define endpoint
local endpoint = "http://192.168.0.100" .. "/api/v1/network/interfaces/eth2/settings"
-- Create client.
local client = HTTPClient.create()
-- Create request.
local request = HTTPClient.Request.create()
request:setURL(endpoint)
request:setMethod("PUT")
-- Prepare credentials.
request:addHeader("accept", "application/json")
request:addHeader("Authorization", "Bearer " .. "TOKEN_HASH")

-- Prepare setting data.
request:setContentType("application/json")
request:setContentBuffer('{"enabled":true, "useDhcp":true, "staticAddress":"", "defaultGateway": "", "dhcpFallbackAddress":"192.168.2.1/24", "dhcpFallbackGateway":"0.0.0.0", "dhcpFallbackMode":0}')

-- Execute.
local response = client:execute(request)  
-- Get status code & json response.
local response_code = response:getStatusCode()
local response_body = response:getContent()

1.4.2. Set Static IP Address

  • JSON data
{
  "enabled":true,
  "useDhcp":false,
  "staticAddress":"192.168.2.100/24",
  "defaultGateway": "192.168.2.1",
  "dhcpFallbackAddress":"",
  "dhcpFallbackGateway":"",
  "dhcpFallbackMode":0
}
  • LUA example code
-- Define endpoint
local endpoint = "http://192.168.0.100" .. "/api/v1/network/interfaces/eth2/settings"
-- Create client.
local client = HTTPClient.create()
-- Create request.
local request = HTTPClient.Request.create()
request:setURL(endpoint)
request:setMethod("PUT")
-- Prepare credentials.
request:addHeader("accept", "application/json")
request:addHeader("Authorization", "Bearer " .. "TOKEN_HASH")

-- Prepare setting data.
request:setContentType("application/json")
request:setContentBuffer('{"enabled":true, "useDhcp":false, "staticAddress":"192.168.2.100/24", "defaultGateway": "192.168.2.1", "dhcpFallbackAddress":"", "dhcpFallbackGateway":"", "dhcpFallbackMode":0}')

-- Execute.
local response = client:execute(request)  
-- Get status code & json response.
local response_code = response:getStatusCode()
local response_body = response:getContent()

Download Example Code

1.4.3. Set an Fallback IP Address

Fallback address can be set using the same code snippet as for setting dhcp or static IP, see sections:

1.5. Setup WLAN Interface

Check sections below for further WLAN setup details.

Download Example Code

1.5.1. Get WLAN Interface Name

Get TDC Wireless interface name.

  • LUA example code
-- Define endpoint.
local endpoint = "http://192.168.0.100" .. "/api/v1/network/wlan"
-- Create client.
local client = HTTPClient.create()
-- Create request.
local request = HTTPClient.Request.create()
request:setURL(endpoint)
request:setMethod("GET")
-- Prepare credentials.
request:addHeader("accept", "application/json")
request:addHeader("Authorization", "Bearer " .. "TOKEN_HASH")

-- Execute.
local response = client:execute(request)
-- Get status code & json response.
local response_code = response:getStatusCode()
local response_body = response:getContent()
  • Response
[
  "wlan0"
]

1.5.2. WLAN Interface Details

To get details of a specific WLAN interface, the WLAN name must be known.

  • LUA example code
-- Define endpoint.
local endpoint = "http://192.168.0.100" .. "/api/v1/network/wlan/wlan0"
-- Create client.
local client = HTTPClient.create()
-- Create request.
local request = HTTPClient.Request.create()
request:setURL(endpoint)
request:setMethod("GET")
-- Prepare credentials.
request:addHeader("accept", "application/json")
request:addHeader("Authorization", "Bearer " .. "TOKEN_HASH")

-- Execute.
local response = client:execute(request)
-- Get status code & json response. 
local response_code = response:getStatusCode()
local response_body = response:getContent()
  • Response
{
  "name": "wlan0",
  "settings": {
    "apMode": {
      "channel": 1,
      "encryption": "",
      "hidden": false,
      "restricted": false,
      "ssid": "TDCE-Next"
    },
    "countryCode": "HR",
    "enabled": true,
    "mode": "station",
    "stationMode": {
      "scanningEnabled": true
    }
  },
  "status": {
    "connectedSsid": "my-network1-name",
    "signalLevel": 96,
    "state": "connected"
  }
}

1.5.3. WLAN Network List

Get a list of all available networks (saved or discovered) for one WLAN interface.

The list of available networks can be used when adding a new network.

local endpoint = "http://192.168.0.100" .. "/api/v1/network/wlan/wlan0/networks"
-- Create client.
local client = HTTPClient.create()
-- Create request.
local request = HTTPClient.Request.create()
request:setURL(endpoint)
request:setMethod("GET")
-- Prepare credentials.
request:addHeader("accept", "application/json")
request:addHeader("Authorization", "Bearer " .. "TOKEN_HASH")

-- Execute.
local response = client:execute(request)
-- Get status code & json response. 
local response_code = response:getStatusCode()
local response_body = response:getContent()
  • Response
[
  {
    "actualConnectionState": "connected",
    "bssid": "0a:17:68:88:5d:0d",
    "channel": 0,
    "events": [],
    "flags": [],
    "isInRange": true,
    "quality": 96,
    "reconnect": false,
    "ssid": "my-network1-name",
    "stored": true,
    "strength": -52,
    "username": ""
  },
  {
    "actualConnectionState": "disconnected",
    "bssid": "d4:01:ff:01:f5:aa",
    "channel": 0,
    "events": [],
    "flags": [],
    "isInRange": true,
    "quality": 64,
    "reconnect": false,
    "ssid": "other-network1",
    "stored": false,
    "strength": -68,
    "username": ""
  },
  ...

1.5.4. Add Wireless Network

Connection to a new network requires a JSON body to be sent.

This same example is used when modifying an existing network connection.

{
  "connectionState": "connected",
  "passphrase": "wifipassword",
  "reconnect": true,
  "username": ""
}

Connection state of a new network can be predefined.

  • connectionState should be connected.
 - unknown
 - connected
 - connecting
 - disconnected
 - disconnecting
  • LUA example code
-- Define endpoint.
local endpoint = "http://192.168.0.100" .. "/api/v1/network/wlan/wlan0/networks/my-network1-name"
-- Create client.
local client = HTTPClient.create()
-- Create request.
local request = HTTPClient.Request.create()
request:setURL(endpoint)
request:setMethod("PUT")
-- Prepare credentials.
request:addHeader("accept", "application/json")
request:addHeader("Authorization", "Bearer " .. "TOKEN_HASH")

-- Prepare setting data.
request:setContentType("application/json")
request:setContentBuffer('{"connectionState": "connected","passphrase": "networkpassword","reconnect": true,"username": ""}')

-- Execute.
local response = client:execute(request)
-- Get status code & json response. 
local response_code = response:getStatusCode()
local response_body = response:getContent()

1.5.5. Delete Wireless Network

Removing previously defined wireless network.

-- Define endpoint.
local endpoint = "http://192.168.0.100" .. "/api/v1/network/wlan/wlan0/networks/my-network1-name"
-- Create client.
local client = HTTPClient.create()
-- Create request.
local request = HTTPClient.Request.create()
request:setURL(endpoint)
request:setMethod("DELETE")
-- Prepare credentials.
request:addHeader("accept", "application/json")
request:addHeader("Authorization", "Bearer " .. "TOKEN_HASH")

-- Execute.
local response = client:execute(request)
-- Get status code & json response. 
local response_code = response:getStatusCode()
local response_body = response:getContent()

1.6. Setup Gateway Priority

Get or set gateway priority list.

Download Example Code

1.6.1. Get Gateway Priority List

Request current gateway priority list.

-- Define endpoint
local endpoint = "http://192.168.0.100" .. "/api/v1/network/settings"
-- Create client.
local client = HTTPClient.create()
-- Create request.
local request = HTTPClient.Request.create()
request:setURL(endpoint)
request:setMethod("GET")
-- Prepare credentials.
request:addHeader("accept", "application/json")
request:addHeader("Authorization", "Bearer " .. TOKEN_HASH)

-- Execute.
local response = client:execute(request)  
-- Get status code & json response.
local response_code = response:getStatusCode()
local response_body = response:getContent()
  • Response
{
  "defaultGatewayPriority": [
    "wwan0",
    "eth1",
    "eth2",
    "wlan0"
  ],
  "networkDriver": "ControlCenter"
}

1.6.2. Set Gateway Priority List

Priority list must be sent in the same form as current json response.

  • Content JSON
{
  "defaultGatewayPriority": [
    "eth1",
    "eth2",
    "wlan0",
    "wwan0"
  ],
  "networkDriver": "ControlCenter"
}
  • LUA example code
-- Define endpoint
local endpoint = "http://192.168.0.100" .. "/api/v1/network/settings"
-- Create client.
local client = HTTPClient.create()
-- Create request.
local request = HTTPClient.Request.create()
request:setURL(endpoint)
request:setMethod("PUT")

-- Prepare credentials.
request:addHeader("accept", "application/json")
request:addHeader("Authorization", "Bearer " .. "TOKEN_HASH")

-- Prepare setting data.
request:setContentType("application/json")
request:setContentBuffer('{"defaultGatewayPriority": ["eth2","eth1","wlan0","wwan0"], "networkDriver": "ControlCenter"}')

-- Execute.
local response = client:execute(request)  
-- Get status code & json response.
local response_code = response:getStatusCode()
local response_body = response:getContent()

1.7. Modem

Modem status and setup examples.

Download Example Code

1.7.1. Get Modem Interface

Get list of all modem devices on a system.

  • LUA example code
-- Define endpoint.
local endpoint = "http://192.168.0.100" .. "/api/v1/network/modem"
-- Create client.
local client = HTTPClient.create()
-- Create request.
local request = HTTPClient.Request.create()
request:setURL(endpoint)
request:setMethod("GET")
-- Prepare credentials.
request:addHeader("accept", "application/json")
request:addHeader("Authorization", "Bearer " .. "TOKEN_HASH")

-- Execute.
local response = client:execute(request)
-- Get status code & json response. 
local response_code = response:getStatusCode()
local response_body = response:getContent()
  • Response
[
  "wwan0"
]

1.7.2. Modem Status

Get all modem settings and statuses.

Note: Settings section will not contain password data.

  • Lua example
  -- Define endpoint.
local endpoint = "http://192.168.0.100" .. "/api/v1/network/modem/wwan0"
-- Create client.
local client = HTTPClient.create()
-- Create request.
local request = HTTPClient.Request.create()
request:setURL(endpoint)
request:setMethod("GET")
-- Prepare credentials.
request:addHeader("accept", "application/json")
request:addHeader("Authorization", "Bearer " .. "TOKEN_HASH")

-- Execute.
local response = client:execute(request)
-- Get status code & json response. 
local response_code = response:getStatusCode()
local response_body = response:getContent()
  • Response
{
  "name": "wwan0",
  "settings": {
    "apn": "internet.ht.hr",
    "apnPassword": "",
    "apnUsername": "",
    "connectionEnabled": true,
    "dialString": "",
    "enabled": true,
    "persistConnection": true
  },
  "status": {
    "ccid": "8938599202301824969",
    "currentAccessTechnologies": [
      "lte"
    ],
    "currentCapabilities": [
      "gsmUmts",
      "lte"
    ],
    "dataConnectionInfo": {
      "attempts": 1,
      "connected": true,
      "connectionError": "",
      "dns": [
        "195.29.247.161",
        "195.29.247.162"
      ],
      "downlinkSpeed": 0,
      "duration": 8940,
      "failedAttempts": 0,
      "gateway": "10.153.202.125",
      "ipCidr": "10.153.202.126/30",
      "rxBytes": 1383,
      "startDate": "2025-01-14T07:13:05Z",
      "totalDuration": 8940,
      "totalRxBytes": 1383,
      "totalTxBytes": 678,
      "txBytes": 678,
      "uplinkSpeed": 0
    },
    "imei": "869283050806985",
    "imsi": "219019912652496",
    "lockSimStatus": "simPin2",
    "modemState": "connected",
    "operatorIdentifier": "21901",
    "operatorName": "HT HR",
    "powerState": "on",
    "signalStrength": 100,
    "simActive": true,
    "stateFailedReason": "none"
  }
}

1.7.3. Read Modem Setting

Get modem settings.

Note: Settings section will not contain password data.

  • Lua example
  -- Define endpoint.
local endpoint = "http://192.168.0.100" .. "/api/v1/network/modem/wwan0/settings"
-- Create client.
local client = HTTPClient.create()
-- Create request.
local request = HTTPClient.Request.create()
request:setURL(endpoint)
request:setMethod("GET")
-- Prepare credentials.
request:addHeader("accept", "application/json")
request:addHeader("Authorization", "Bearer " .. "TOKEN_HASH")

-- Execute.
local response = client:execute(request)
-- Get status code & json response. 
local response_code = response:getStatusCode()
local response_body = response:getContent()
  • Response
{
  "apn": "internet.ht.hr",
  "apnPassword": "",
  "apnUsername": "",
  "connectionEnabled": true,
  "dialString": "",
  "enabled": true,
  "persistConnection": true
}

1.7.4. Add Modem Setting

Add modem settings.

To add modem connection setting JSON data is required.

{
  "apn": "string",
  "enabled": true,
  "connectionEnabled": true,
  "apnPassword": "",
  "apnUsername": "",  
  "dialString": "",  
  "persistConnection": true
  }
  • Lua example
  -- Define endpoint.
local endpoint = "http://192.168.0.100" .. "/api/v1/network/modem/wwan0/settings"
-- Create client.
local client = HTTPClient.create()
-- Create request.
local request = HTTPClient.Request.create()
request:setURL(endpoint)
request:setMethod("PUT")
-- Prepare credentials.
request:addHeader("accept", "application/json")
request:addHeader("Authorization", "Bearer " .. "TOKEN_HASH")

-- Prepare setting data.
request:setContentType("application/json")
request:setContentBuffer('{"apn": "internet.ht.hr","enabled": true,"connectionEnabled": true,"apnPassword": "","apnUsername": "","dialString": "","persistConnection": true}')

-- Execute.
local response = client:execute(request)
-- Get status code & json response. 
local response_code = response:getStatusCode()
local response_body = response:getContent()

Code 204 is expected when success.

Wireless Personal Network Examples

In this section, the Wireless Personal Network (WPAN) service on the TDC-X device is discussed. Programming examples are given and thoroughly explained.

1. Go gRPC Example

In this section, a Go gRPC application is created and documented. A gRPC client is created from a Proto file to match the gRPC server the TDC-X device is serving. An application that checks device availability, connects to a device, and reads data from it is provided.

The application uses Golang's gRPC service to fetch and send data to the server.

1.1. Application Implementation

To implement a gRPC client, a Proto file matching the gRPC server's specifications is needed. This Proto file is then placed in a separate package, and from it, gRPC Go files are generated using the following commands:

go install google.golang.org/protobuf/cmd/[email protected]
go install google.golang.org/grpc/cmd/[email protected]

GO111MODULE=on go install github.com/bufbuild/buf/cmd/[email protected]

buf build pkg/
buf generate pkg/

This installs needed Protoc files, and generates files for the gRPC service. The application also requires an access token to connect to the gRPC service the TDC-X provides. Please refer to gRPC Usage for instructions on how to generate an access token for the TDC-X.

Once you've obtained the token, navigate to grpc-wpan/pkg/auth/token.json. Paste your generated token in the access_token field. This token is then used to create a context which will be used to create gRPC calls.

The main application first creates a new gRPC client that connects to the TDC-X IP address on port 8081. It does so by using the generated gRPC Go file specification.

conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		log.Fatalf("did not connect: %v", err)
	}
	defer conn.Close()

Providing WPAN discovery is on, the application first reads all available WPAN devices and prints them to the console in a readable format. Otherwise, the application retries fetching the WPAN devices 5 times before exiting the application.

for retries < maxRetries {
		resp, err := client.GetDevices(ctx, &emptypb.Empty{})
		if err != nil {
			log.Fatalf("could not read: %v", err)
		}

		if len(resp.Devices) > 0 {
			for _, device := range resp.Devices {
				log.Println("Device Alias:", derefString(device.Alias))
				log.Println("Device Address:", derefString(device.Address))
				log.Println("Device UUIDs:", device.Uuids)
				log.Println("Device RSSI:", derefInt32(device.Rssi))
				log.Println("Paired:", derefBool(device.Paired))
				log.Println("Bonded:", derefBool(device.Bonded))
				log.Println("Connected:", derefBool(device.Connected))
				log.Println("Trusted:", derefBool(device.Trusted))
				log.Println("Blocked:", derefBool(device.Blocked))
				log.Println("Services Resolved:", derefBool(device.ServicesResolved))
				log.Println("----")
			}
			return
      }
}

To see how to turn the device discovery process on, go to the next section. It's a good practice to refresh the discovery process once in a while if your device doesn't appear on the list.

The application then sleeps for 2 seconds.

In the code, set the deviceMacAddress variable, which currently holds a placeholder value. The application will then attempt to pair with and connect the WPAN device to the TDC-X. To do so, the application first checks the availability of the set MAC address using the hal.wpan.WPAN.GetDevice HAL service.

func checkDevice(macAddress string) bool {
	req := protos.DeviceAddress{
		Address: macAddress,
	}
	_, err := client.GetDevice(ctx, &req)
	if err != nil {
		log.Printf("MAC address %s not in available devices: %v. Stopping application...", macAddress, err)
		return false
	}
	return true
}

If the device MAC address is not discovered, the application prints an appropriate log and exits. Otherwise, the Go application proceeds to pair with and connect to the device.

NOTE: Pairing is a stream in which the pairing status is possibly preceded by displaying a pass key. Pairing usually precedes connecting to the device, but it's also possible that pairing is handled automatically when trying to establish a connection.

The application enters a streaming mode which prints all data received by the WPAN device to the console.

func attachToHDIInput(macAddress string) {
	req := protos.DeviceAddress{
		Address: macAddress,
	}
	stream, err := client.AttachToHIDInput(ctx, &req)
	if err != nil {
		log.Fatalf("Failed to pair %s: %v", macAddress, err)
	}
	for {
		inputEvent, err := stream.Recv()
		if err != nil {
			if err == io.EOF {
				log.Println("Stream closed.")
				break
			}
			log.Fatalf("Error receiving input event: %v", err)
		}
		log.Printf("Received input event: %+v\n", inputEvent)
	}
}

See an example print of the data stream after connecting to a device below:

2025/01/10 19:09:43 ----
2025/01/10 19:09:55 Received input event: time:{sec:1736536195 nsec:747679000} type:4 code:4 value:458835
2025/01/10 19:09:55 Received input event: time:{sec:1736536195 nsec:747679000} type:1 code:69 value:1
2025/01/10 19:09:55 Received input event: time:{sec:1736536195 nsec:747679000}
2025/01/10 19:09:55 Received input event: time:{sec:1736536195 nsec:758890000} type:17 value:1
2025/01/10 19:09:55 Received input event: time:{sec:1736536195 nsec:758890000} type:4 code:4 value:458835
2025/01/10 19:09:55 Received input event: time:{sec:1736536195 nsec:758890000} type:1 code:69
2025/01/10 19:09:55 Received input event: time:{sec:1736536195 nsec:758890000}
2025/01/10 19:09:55 Received input event: time:{sec:1736536195 nsec:770142000} type:4 code:4 value:458835
2025/01/10 19:09:55 Received input event: time:{sec:1736536195 nsec:770142000} type:1 code:69 value:1
2025/01/10 19:09:55 Received input event: time:{sec:1736536195 nsec:770142000}
2025/01/10 19:09:55 Received input event: time:{sec:1736536195 nsec:780145000} type:17
2025/01/10 19:09:55 Received input event: time:{sec:1736536195 nsec:780145000} type:4 code:4 value:458835
2025/01/10 19:09:55 Received input event: time:{sec:1736536195 nsec:780145000} type:1 code:69
2025/01/10 19:09:55 Received input event: time:{sec:1736536195 nsec:780145000}
2025/01/10 19:09:55 Received input event: time:{sec:1736536195 nsec:781398000} type:4 code:4 value:458784
2025/01/10 19:09:55 Received input event: time:{sec:1736536195 nsec:781398000} type:1 code:4 value:1

1.2. Proto File

The Proto file used for the WPAN service is the following:

/**
 * WPAN service.
 *
 * Service that enables WPAN functionalities using bluez.
 */
syntax = "proto3";

package hal.wpan;
 
import "google/protobuf/empty.proto";
 
option go_package = "./protos;protos";

/**
 * Represents WPAN service status.
 */
message ServiceStatusData {
   bool enabled = 1; 
}

/**
 * Represents type of the scan.
 */
enum ScanType {
    Auto = 0;
    BrEdr = 1;
    Le = 2;
}

/**
 * Represents discovery filters.
 */
message DiscoveryFilters {
   repeated string uuids = 1;
   optional int32 rssi = 2;
   optional int32 pathloss = 3;
   optional ScanType transport = 4;
   optional bool duplicateData = 5;
   optional bool discoverable = 6;
   optional string pattern = 7;
}

/**
 * Represents device data.
 */
message DeviceData {
   optional string alias = 1;
   optional string address = 2;
   repeated string uuids = 3;
   optional int32 rssi = 4;
   optional bool paired = 5;
   optional bool bonded = 6;
   optional bool connected = 7;
   optional bool trusted = 8;
   optional bool blocked = 9;
   optional bool servicesResolved = 10;
}

/**
 * Represents devices.
 */
message Devices {
   repeated DeviceData devices = 1;
}

/**
 * Represents device address.
 */
message DeviceAddress {
   string address = 1;
}

/**
 * Represents device connection request.
 */
message DeviceConnection {
   string address = 1;
   optional string profile = 2;
}

/**
 * Represents type of pairing process display.
 */
enum DisplayType {
    Pincode = 0;
    Passkey = 1;
}

/**
 * Represents device pairing process display.
 */
message PairingProcessDisplay {
   DisplayType type = 1;
   string content = 2;
}

/**
 * Represents device pairing status.
 */
message PairingStatus {
   bool paired = 1;
   string errMsg = 2;
}

/**
 * Represents device pairing response.
 */
message PairingResponse {
   oneof response {
      PairingStatus status = 1;
      PairingProcessDisplay pairing = 2;
   }
}

/**
 * Represents input HID device basic information.
 */
message HidDeviceData {
   string address = 1;
   string name = 2;
}

/**
 * Represents array of input HID devices.
 */
message HidDevices {
   repeated HidDeviceData hidDevices = 1;
}

/**
 * Represents syscall UNIX time value.
 */
message SyscallTime {
   int64 sec = 1;
   int64 nsec = 2;
}

/**
 * Represents input event from input HID device.
 * HID input events based on:
 *  - https://raw.githubusercontent.com/torvalds/linux/v6.7/include/uapi/linux/input.h
 *  - https://raw.githubusercontent.com/torvalds/linux/v6.7/include/uapi/linux/input-event-codes.h
 */
message InputEvent {
   // Time in seconds since epoch at which event occurred.
   SyscallTime time = 1;

   // Event type.
   int32 type = 2;

   // Event code related to the event type.
   int32 code = 3;

   // Event value related to the event type.
   int32 value = 4;
}
 
/**
 * Service exposing WPAN functions.
 */
service WPAN {
  // Used to enable/disable WPAN on device.
  rpc ToggleService(ServiceStatusData) returns (google.protobuf.Empty) {}

  // Used to get WPAN service status.
  rpc ServiceStatus(google.protobuf.Empty) returns (ServiceStatusData) {}

  // Used to start WPAN discovery.
  rpc StartDiscovery(DiscoveryFilters) returns (google.protobuf.Empty) {}

  // Used to stop WPAN discovery.
  rpc StopDiscovery(google.protobuf.Empty) returns (google.protobuf.Empty) {}

  // Used to retrieve found (and stored) devices.
  rpc GetDevices(google.protobuf.Empty) returns (Devices) {}

  // Used to retrieve device with specified address.
  rpc GetDevice(DeviceAddress) returns (DeviceData) {}

  // Used to remove all devices.
  rpc RemoveDevices(google.protobuf.Empty) returns (google.protobuf.Empty) {}

  // Used to remove device with specified address.
  rpc RemoveDevice(DeviceAddress) returns (google.protobuf.Empty) {}

  // Used to pair to device with specified address.
  rpc Pair(DeviceAddress) returns (stream PairingResponse) {}

  // Used to connect with device with specified address.
  rpc Connect(DeviceConnection) returns (google.protobuf.Empty) {}

  // Used to disconnect from device with specified address.
  rpc Disconnect(DeviceConnection) returns (google.protobuf.Empty) {}

  // Used to get all HID inputs connected via WPAN HID profile.
  rpc GetHIDInputs(google.protobuf.Empty) returns (HidDevices) {}

  // Used to attach to and listen for input events from HID input.
  rpc AttachToHIDInput(DeviceAddress) returns (stream InputEvent) {}
}

To get the WPAN service status, use grpcurl, which is an open-source utility for accessing gRPC services via the shell. For help setting up the grpcurl command, refer to gRPC Usage.

Use the following line:

grpcurl -expand-headers -H 'Authorization: Bearer <token>' -emit-defaults -plaintext <device_ip>:<grpc_server_port> hal.wpan.WPAN.ServiceStatus

The token field is the fetched TDC-X authorization token. For help fetching this token, see gRPC Usage. The device-ip:grpc_server_port is the TDC-X IP address and the gRPC serving port. For example, if the token value was token and the address and port were 192.168.0.100:8081, you would use the following line to see the device status of the WPAN service.

grpcurl -expand-headers -H 'Authorization: Bearer token' -emit-defaults -plaintext 192.168.0.100:8081 hal.wpan.WPAN.ServiceStatus

The response should be in this format:

{
  "enabled": true
}

If not enabled, use the following line to do so:

grpcurl -expand-headers -H 'Authorization: Bearer token' -emit-defaults -plaintext -d '{"enabled":true}' 192.168.0.100:8081 hal.wpan.WPAN.ToggleService
{}

To start the discovery process of WPAN devices, use the following grpcurl command:

grpcurl -expand-headers -H 'Authorization: Bearer token' -emit-defaults -plaintext -d '{"uuids":["00001101-0000-1000-8000-00805f9b34fb"]}' 192.168.0.100:8081 hal.wpan.WPAN.StartDiscovery
{}

This ensures the discovery process starts early. This particular command searches for all devices which use the 00001101-0000-1000-8000-00805f9b34fb UUID, which serves as a filter for WPAN devices. To stop the discovery process, use the following command:

grpcurl -expand-headers -H 'Authorization: Bearer token' -emit-defaults -plaintext 192.168.0.100:8081 hal.wpan.WPAN.StopDiscovery
{}

Additionally, you can use the gRPC Clicker VSCode extension for working with gRPC services. For help setting the service up, refer to gRPC Usage.

NOTE: If experiencing gRPC service timeouts when accessing a WPAN device, you can resolve this by adding a max_time parameter to the gRPC call. Below is an example of how to set the timeout to 10 seconds. Adjust the timeout as needed.

grpcurl -d '{"address":"MAC_ADDR"}' -H 'Authorization: Bearer token' -plaintext -max-time 10 192.168.0.100:8081 hal.wpan.WPAN/GetDevice

1.3. Application Deployment

1.2.1. Dockerfile

To deploy the application, a Go container should be created and deployed to the TDC-X. To that end, a Dockerfile is created. The file is shown below.

# build image for Go app
FROM golang:1.22.0 AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download
COPY . .

# setting environment
ENV GOOS=linux
ENV GOARCH=arm64

RUN go build -o wpan-grpc ./cmd/main.go

# runtime image
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/wpan-grpc .

CMD ["./wpan-grpc"]

Open a terminal and paste the following commands:

docker build -t wpan-grpc-app .
docker save -o wpan-grpc-app.tar wpan-grpc-app:latest

This will build the docker container and save the application as a .tar file which can be used for Portainer upload.

1.2.2. Dockerfile Breakdown

The Dockerfile first creates a build image that is used to build the Go application. It sets the working directory as /app, then copies the go.mod and go.sum files to the directory and downloads all needed files.

COPY go.mod go.sum ./
RUN go mod download
COPY . .

Next, the Go environment is set. This needs to be done as the TDC-X device has the arm64 architecture and is based on Linux. With this in mind, the application is set to the following:

ENV GOOS=linux
ENV GOARCH=arm64

The application is built as wpan-grpc.

RUN go build -o wpan-grpc ./cmd/main.go

Finally, a runtime image is created from the latest alpine version for a smaller image size. The working directory is once again set to /app, and the application is copied. The last line specifies that, upon deployment, the wpan-grpc application is started.

FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/wpan-grpc .

CMD ["./wpan-grpc"]

1.2.3. Deploying to Portainer

To deploy the application to the TDC-X device, Portainer can be used. To see instructions on the process, refer to Working with Portainer. As soon as the image and container are set up, the application starts running.

Download the example code from the link below:

Download Example Code

2. Node-RED gRPC Example

This section describes usage of the WPAN service on the TDC-X. A Node-RED gRPC application was created to demonstrate the following WPAN functionalities:

  • getting / setting service status
  • starting / stopping discovery
  • listing devices
  • listing device parameters
  • pairing device
  • connecting device
  • attaching HID inputs from device

For implementation, the following nodes are used:

  • inject node
  • gRPC call node
  • debug node.

The gRPC node set is not part of the initial Node-RED package list and will have to be installed to the Palette. For gRPC node installation, import the following file in the Manage Palette section: Download Node

The gRPC node server needs to be set properly to be able to connect to the gRPC server and interpret server results correctly. The following configuration is used:

  • Server:192.168.0.100
  • Port:8081
  • a Proto File is provided.

To test out any of the listed functionalities, use the inject node. The result should appear in the debug node.

See a screenshot of the application below:

wpan Node-RED Example

Download the example code from the link below:

Download Example Code

Clone this wiki locally