Replace gRPC Backend (#10)

**Rationale:**

Having two separate servers and communication methods resulted in additional maintenance & the need to convert often between backend & frontend data types.
By moving the backend communication off of gRPC and to just use websockets it both gives more control & allows for simplification of the implementation.

#8

**Changes:**

- Replaces gRPC backend.
  - New implementation automatically handles reconnect logic
- Implements an api layer
- Migrates examples to the api layer
- Implements a proc macro to make command handling easier
- Implements unit tests for the api layer (90+% coverage)
- Implements integration tests for the proc macro (90+% coverage)

Reviewed-on: #10
Co-authored-by: Sergey Savelyev <sergeysav.nn@gmail.com>
Co-committed-by: Sergey Savelyev <sergeysav.nn@gmail.com>
This commit was merged in pull request #10.
This commit is contained in:
2026-01-01 10:11:53 -08:00
committed by sergeysav
parent f658b55586
commit 788dd10a91
68 changed files with 3934 additions and 1504 deletions

View File

@@ -4,11 +4,9 @@ name = "simple_command"
edition = "2021"
[dependencies]
server = { path = "../../server" }
tonic = "0.12.3"
tokio = { version = "1.43.0", features = ["rt-multi-thread", "signal"] }
chrono = "0.4.39"
tokio-util = "0.7.13"
num-traits = "0.2.19"
log = "0.4.29"
anyhow = "1.0.100"
anyhow = { workspace = true }
api = { path = "../../api" }
env_logger = { workspace = true }
log = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "signal"] }
tokio-util = { workspace = true }

View File

@@ -1,82 +1,16 @@
use chrono::DateTime;
use server::core::client_side_command::Inner;
use server::core::command_service_client::CommandServiceClient;
use server::core::telemetry_value::Value;
use server::core::{
ClientSideCommand, Command, CommandDefinitionRequest, CommandParameterDefinition,
CommandResponse, TelemetryDataType,
};
use api::client::command::CommandRegistry;
use api::client::Client;
use api::macros::IntoCommandDefinition;
use api::messages::command::CommandHeader;
use log::info;
use std::error::Error;
use tokio::select;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use tonic::codegen::tokio_stream::wrappers::ReceiverStream;
use tonic::codegen::tokio_stream::StreamExt;
use tonic::transport::Channel;
struct CommandHandler {
handle: JoinHandle<()>,
}
impl CommandHandler {
pub async fn new<F: Fn(Command) -> CommandResponse + Send + 'static>(
cancellation_token: CancellationToken,
client: &mut CommandServiceClient<Channel>,
command_definition_request: CommandDefinitionRequest,
handler: F,
) -> anyhow::Result<Self> {
let (tx, rx) = mpsc::channel(4);
// The buffer size of 4 means this is safe to send immediately
tx.send(ClientSideCommand {
inner: Some(Inner::Request(command_definition_request)),
})
.await?;
let response = client.new_command(ReceiverStream::new(rx)).await?;
let mut cmd_stream = response.into_inner();
let handle = tokio::spawn(async move {
loop {
select! {
_ = cancellation_token.cancelled() => break,
Some(msg) = cmd_stream.next() => {
match msg {
Ok(cmd) => {
let uuid = cmd.uuid.clone();
let mut response = handler(cmd);
response.uuid = uuid;
match tx.send(ClientSideCommand {
inner: Some(Inner::Response(response))
}).await {
Ok(()) => {},
Err(e) => {
println!("SendError: {e}");
break;
}
}
}
Err(e) => {
println!("Error: {e}");
break;
}
}
},
else => break,
}
}
});
Ok(Self { handle })
}
pub async fn join(self) -> anyhow::Result<()> {
Ok(self.handle.await?)
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
env_logger::init();
let cancellation_token = CancellationToken::new();
{
let cancellation_token = cancellation_token.clone();
@@ -86,56 +20,34 @@ async fn main() -> Result<(), Box<dyn Error>> {
});
}
let mut client = CommandServiceClient::connect("http://[::1]:50051").await?;
let client = Arc::new(Client::connect("ws://localhost:8080/backend")?);
let cmd = CommandRegistry::new(client);
let cmd_handler = CommandHandler::new(
cancellation_token,
&mut client,
CommandDefinitionRequest {
name: "simple_command/a".to_string(),
parameters: vec![
CommandParameterDefinition {
name: "a".to_string(),
data_type: TelemetryDataType::Float32.into(),
},
CommandParameterDefinition {
name: "b".to_string(),
data_type: TelemetryDataType::Float64.into(),
},
CommandParameterDefinition {
name: "c".to_string(),
data_type: TelemetryDataType::Boolean.into(),
},
],
},
|command| {
let timestamp = command.timestamp.expect("Missing Timestamp");
let timestamp = DateTime::from_timestamp(timestamp.secs, timestamp.nanos as u32)
.expect("Could not construct date time");
let Value::Float32(a) = command.parameters["a"].value.expect("Missing Value a") else {
panic!("Wrong Type a");
};
let Value::Float64(b) = command.parameters["b"].value.expect("Missing Value b") else {
panic!("Wrong Type b");
};
let Value::Boolean(c) = command.parameters["c"].value.expect("Missing Value c") else {
panic!("Wrong Type c");
};
let handle = cmd.register_handler("simple_command/a", handle_command);
println!("Command Received:\n timestamp: {timestamp}\n a: {a}\n b: {b}\n c: {c}");
cancellation_token.cancelled().await;
CommandResponse {
uuid: command.uuid.clone(),
success: true,
response: format!(
"Successfully Received Command! timestamp: {timestamp} a: {a} b: {b} c: {c}"
),
}
},
)
.await?;
cmd_handler.join().await?;
// This will automatically drop when we return
drop(handle);
Ok(())
}
#[derive(IntoCommandDefinition)]
struct SimpleCommandA {
a: f32,
b: f64,
c: bool,
}
fn handle_command(header: CommandHeader, command: SimpleCommandA) -> anyhow::Result<String> {
let timestamp = header.timestamp;
let SimpleCommandA { a, b, c } = command;
info!("Command Received:\n timestamp: {timestamp}\n a: {a}\n b: {b}\n c: {c}");
// This gets sent back to the source of the command
Ok(format!(
"Successfully Received Command! timestamp: {timestamp} a: {a} b: {b} c: {c}"
))
}