diff --git a/Cargo.toml b/Cargo.toml index af169ea59..5147e6816 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ members = [ "core/main", "core/launcher", "device/thunder", + "device/mock_device", "distributor/general", "examples/rpc_extn", "examples/tm_extn", diff --git a/core/main/Cargo.toml b/core/main/Cargo.toml index f946f9b44..38c763f24 100644 --- a/core/main/Cargo.toml +++ b/core/main/Cargo.toml @@ -50,7 +50,6 @@ sd-notify = { version = "0.4.1", optional = true } exitcode = "1.1.2" rand = "0.8" - [build-dependencies] vergen = "1" diff --git a/core/main/src/bootstrap/extn/load_extn_step.rs b/core/main/src/bootstrap/extn/load_extn_step.rs index 42738ec4d..df9051a38 100644 --- a/core/main/src/bootstrap/extn/load_extn_step.rs +++ b/core/main/src/bootstrap/extn/load_extn_step.rs @@ -84,7 +84,7 @@ impl Bootstep for LoadExtensionsStep { return Err(RippleError::BootstrapError); } } else { - error!("invalid channel builder in {}", path); + error!("failed loading builder in {}", path); return Err(RippleError::BootstrapError); } } else { diff --git a/core/main/src/state/bootstrap_state.rs b/core/main/src/state/bootstrap_state.rs index 2d8bb1cee..4c19c6524 100644 --- a/core/main/src/state/bootstrap_state.rs +++ b/core/main/src/state/bootstrap_state.rs @@ -49,6 +49,7 @@ impl ChannelsState { let (gateway_tx, gateway_tr) = mpsc::channel(32); let (app_req_tx, app_req_tr) = mpsc::channel(32); let (ctx, ctr) = unbounded(); + ChannelsState { gateway_channel: TransientChannel::new(gateway_tx, gateway_tr), app_req_channel: TransientChannel::new(app_req_tx, app_req_tr), diff --git a/core/main/src/state/platform_state.rs b/core/main/src/state/platform_state.rs index 5dfb7f400..eb4b95698 100644 --- a/core/main/src/state/platform_state.rs +++ b/core/main/src/state/platform_state.rs @@ -115,7 +115,6 @@ impl PlatformState { version: Option, ) -> PlatformState { let exclusory = ExclusoryImpl::get(&manifest); - Self { extn_manifest, cap_state: CapState::new(manifest.clone()), diff --git a/core/main/src/utils/rpc_utils.rs b/core/main/src/utils/rpc_utils.rs index ef2cb1759..def02e7c1 100644 --- a/core/main/src/utils/rpc_utils.rs +++ b/core/main/src/utils/rpc_utils.rs @@ -32,14 +32,12 @@ use crate::{ state::platform_state::PlatformState, }; +pub use ripple_sdk::utils::rpc_utils::rpc_err; + pub const FIRE_BOLT_DEEPLINK_ERROR_CODE: i32 = -40400; pub const DOWNSTREAM_SERVICE_UNAVAILABLE_ERROR_CODE: i32 = -50200; pub const SESSION_NO_INTENT_ERROR_CODE: i32 = -40000; -pub fn rpc_err(msg: impl Into) -> Error { - Error::Custom(msg.into()) -} - /// Awaits a oneshot to respond. If the oneshot fails to repond, creates a generic /// RPC internal error pub async fn rpc_await_oneshot(rx: oneshot::Receiver) -> RpcResult { diff --git a/core/sdk/src/extn/client/extn_client.rs b/core/sdk/src/extn/client/extn_client.rs index f80e15cc0..303f56107 100644 --- a/core/sdk/src/extn/client/extn_client.rs +++ b/core/sdk/src/extn/client/extn_client.rs @@ -294,6 +294,22 @@ impl ExtnClient { { self.context_update(request); } + // if its a request coming as an extn provider the extension is calling on itself. + // for eg an extension has a RPC Method provider and also a channel to process the + // requests this below impl will take care of sending the data back to the Extension + else if let Some(extn_id) = target_contract.is_extn_provider() { + if let Some(s) = self.get_extn_sender_with_extn_id(&extn_id) { + let new_message = message.clone(); + tokio::spawn(async move { + if let Err(e) = s.send(new_message.into()).await { + error!("Error forwarding request {:?}", e) + } + }); + } else { + error!("couldn't find the extension id registered the extn channel {:?} is not available", extn_id); + self.handle_no_processor_error(message); + } + } // Forward the message to an extn sender else if let Some(sender) = self.get_extn_sender_with_contract(target_contract) { @@ -600,7 +616,7 @@ impl ExtnClient { /// Request method which accepts a impl [ExtnPayloadProvider] and uses the capability provided by the trait to send the request. /// As part of the send process it adds a callback to asynchronously respond back to the caller when the response does get - /// received. This method can be called synchrnously with a timeout + /// received. This method can be called synchronously with a timeout /// /// # Arguments /// `payload` - impl [ExtnPayloadProvider] diff --git a/core/sdk/src/extn/client/extn_processor.rs b/core/sdk/src/extn/client/extn_processor.rs index 44fd71686..9d25bcc30 100644 --- a/core/sdk/src/extn/client/extn_processor.rs +++ b/core/sdk/src/extn/client/extn_processor.rs @@ -175,10 +175,9 @@ pub trait ExtnRequestProcessor: ExtnStreamProcessor + Send + Sync + 'static { /// /// # Returns /// - /// `Option` -> Used by [ExtnClient] to handle post processing - /// None - means not processed - /// Some(true) - Successful processing with status success - /// Some(false) - Successful processing with status error + /// `bool` -> Used by [ExtnClient] to handle post processing + /// `true` - Successful processing with status success + /// `false` - Successful processing with status error async fn process_request( state: Self::STATE, msg: ExtnMessage, diff --git a/core/sdk/src/extn/client/extn_sender.rs b/core/sdk/src/extn/client/extn_sender.rs index 3eb5b7f68..885900630 100644 --- a/core/sdk/src/extn/client/extn_sender.rs +++ b/core/sdk/src/extn/client/extn_sender.rs @@ -80,10 +80,12 @@ impl ExtnSender { } pub fn check_contract_fulfillment(&self, contract: RippleContract) -> bool { - if self.id.is_main() { + if self.id.is_main() || self.fulfills.contains(&contract.as_clear_string()) { true + } else if let Ok(extn_id) = ExtnId::try_from(contract.as_clear_string()) { + self.id.eq(&extn_id) } else { - self.fulfills.contains(&contract.as_clear_string()) + false } } diff --git a/core/sdk/src/extn/extn_id.rs b/core/sdk/src/extn/extn_id.rs index 6503369ae..c364d0650 100644 --- a/core/sdk/src/extn/extn_id.rs +++ b/core/sdk/src/extn/extn_id.rs @@ -15,7 +15,14 @@ // SPDX-License-Identifier: Apache-2.0 // -use crate::utils::error::RippleError; +use crate::{ + framework::ripple_contract::{ContractAdjective, RippleContract}, + utils::error::RippleError, +}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; + +use super::extn_client_message::{ExtnPayload, ExtnPayloadProvider, ExtnRequest, ExtnResponse}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum ExtnClassId { @@ -133,13 +140,36 @@ impl ExtnClassType { /// Below capability means the given plugin offers a JsonRpsee rpc extension for a service named bridge /// /// `ripple:extn:jsonrpsee:bridge` -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct ExtnId { pub _type: ExtnType, pub class: ExtnClassId, pub service: String, } +impl Serialize for ExtnId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for ExtnId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + if let Ok(str) = String::deserialize(deserializer) { + if let Ok(id) = ExtnId::try_from(str) { + return Ok(id); + } + } + Err(serde::de::Error::unknown_variant("unknown", &["unknown"])) + } +} + impl ToString for ExtnId { fn to_string(&self) -> String { let r = format!( @@ -406,9 +436,100 @@ impl ExtnId { } } -impl PartialEq for ExtnId { - fn eq(&self, other: &ExtnId) -> bool { - self._type == other._type && self.class == other.class +#[derive(Debug, Clone, PartialEq)] +pub struct ExtnProviderAdjective { + pub id: ExtnId, +} + +impl Serialize for ExtnProviderAdjective { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.id.to_string()) + } +} + +impl<'de> Deserialize<'de> for ExtnProviderAdjective { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + if let Ok(str) = String::deserialize(deserializer) { + if let Ok(id) = ExtnId::try_from(str) { + return Ok(ExtnProviderAdjective { id }); + } + } + Err(serde::de::Error::unknown_variant("unknown", &["unknown"])) + } +} + +impl ContractAdjective for ExtnProviderAdjective { + fn get_contract(&self) -> RippleContract { + RippleContract::ExtnProvider(self.clone()) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExtnProviderRequest { + pub value: Value, + pub id: ExtnId, +} + +impl ExtnPayloadProvider for ExtnProviderRequest { + fn get_from_payload(payload: ExtnPayload) -> Option { + if let ExtnPayload::Request(ExtnRequest::Extn(value)) = payload { + if let Ok(v) = serde_json::from_value::(value) { + return Some(v); + } + } + + None + } + + fn get_extn_payload(&self) -> ExtnPayload { + ExtnPayload::Request(ExtnRequest::Extn( + serde_json::to_value(self.clone()).unwrap(), + )) + } + + fn contract() -> RippleContract { + // Will be replaced by the IEC before CExtnMessage conversion + RippleContract::ExtnProvider(ExtnProviderAdjective { + id: ExtnId::get_main_target("default".into()), + }) + } + + fn get_contract(&self) -> RippleContract { + RippleContract::ExtnProvider(ExtnProviderAdjective { + id: self.id.clone(), + }) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExtnProviderResponse { + pub value: Value, +} + +impl ExtnPayloadProvider for ExtnProviderResponse { + fn get_from_payload(payload: ExtnPayload) -> Option { + if let ExtnPayload::Response(ExtnResponse::Value(value)) = payload { + return Some(ExtnProviderResponse { value }); + } + + None + } + + fn get_extn_payload(&self) -> ExtnPayload { + ExtnPayload::Response(ExtnResponse::Value(self.value.clone())) + } + + fn contract() -> RippleContract { + // Will be replaced by the IEC before CExtnMessage conversion + RippleContract::ExtnProvider(ExtnProviderAdjective { + id: ExtnId::get_main_target("default".into()), + }) } } diff --git a/core/sdk/src/extn/ffi/ffi_library.rs b/core/sdk/src/extn/ffi/ffi_library.rs index ef4328f5c..d7f8787ed 100644 --- a/core/sdk/src/extn/ffi/ffi_library.rs +++ b/core/sdk/src/extn/ffi/ffi_library.rs @@ -46,7 +46,7 @@ pub struct ExtnSymbolMetadata { } #[repr(C)] -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct CExtnMetadata { pub name: String, pub metadata: String, diff --git a/core/sdk/src/extn/ffi/ffi_message.rs b/core/sdk/src/extn/ffi/ffi_message.rs index 788783166..2385f5de6 100644 --- a/core/sdk/src/extn/ffi/ffi_message.rs +++ b/core/sdk/src/extn/ffi/ffi_message.rs @@ -185,7 +185,7 @@ mod tests { // Create a mock CExtnMessage let c_extn_message = CExtnMessage { id: "test_id".to_string(), - requestor, + requestor: requestor.clone(), target, target_id: "".to_string(), payload, @@ -201,10 +201,7 @@ mod tests { assert!(extn_message.is_ok(), "Expected Ok, but got Err"); if let Ok(extn_message) = extn_message { assert_eq!(extn_message.id, "test_id"); - assert_eq!( - extn_message.requestor, - ExtnId::new_channel(ExtnClassId::Device, "info".to_string()) - ); + assert_eq!(extn_message.requestor.to_string(), requestor); assert_eq!(extn_message.target, RippleContract::DeviceInfo); assert_eq!(extn_message.target_id, None); assert_eq!( diff --git a/core/sdk/src/framework/ripple_contract.rs b/core/sdk/src/framework/ripple_contract.rs index 8533eefd9..6dc0163b9 100644 --- a/core/sdk/src/framework/ripple_contract.rs +++ b/core/sdk/src/framework/ripple_contract.rs @@ -20,8 +20,10 @@ use crate::{ session::{EventAdjective, PubSubAdjective, SessionAdjective}, storage_property::StorageAdjective, }, + extn::extn_id::ExtnProviderAdjective, utils::{error::RippleError, serde_utils::SerdeClearString}, }; +use jsonrpsee_core::DeserializeOwned; use log::error; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -122,13 +124,14 @@ pub enum RippleContract { /// the Session information based on their policies. Used by [crate::api::session::AccountSession] Session(SessionAdjective), RippleContext, + ExtnProvider(ExtnProviderAdjective), AppCatalog, Apps, // Runtime ability for a given distributor to turn off a certian feature RemoteFeatureControl, } -pub trait ContractAdjective: serde::ser::Serialize { +pub trait ContractAdjective: serde::ser::Serialize + DeserializeOwned { fn as_string(&self) -> String { let adjective = SerdeClearString::as_clear_string(self); if let Some(contract) = self.get_contract().get_adjective_contract() { @@ -185,13 +188,27 @@ impl RippleContract { Self::Session(adj) => Some(adj.as_string()), Self::PubSub(adj) => Some(adj.as_string()), Self::DeviceEvents(adj) => Some(adj.as_string()), + Self::ExtnProvider(adj) => Some(adj.id.to_string()), _ => None, } } + fn get_contract_from_adjective(str: &str) -> Option { + match serde_json::from_str::(str) { + Ok(v) => Some(v.get_contract()), + Err(e) => { + error!("contract parser_error={:?}", e); + None + } + } + } + pub fn from_adjective_string(contract: &str, adjective: &str) -> Option { let adjective = format!("\"{}\"", adjective); match contract { + "extn_provider" => { + return Self::get_contract_from_adjective::(&adjective) + } "storage" => match serde_json::from_str::(&adjective) { Ok(v) => return Some(v.get_contract()), Err(e) => error!("contract parser_error={:?}", e), @@ -217,6 +234,7 @@ impl RippleContract { match self { Self::Storage(_) => Some("storage".to_owned()), Self::Session(_) => Some("session".to_owned()), + Self::ExtnProvider(_) => Some("extn_provider".to_owned()), Self::PubSub(_) => Some("pubsub".to_owned()), Self::DeviceEvents(_) => Some("device_events".to_owned()), _ => None, @@ -238,6 +256,14 @@ impl RippleContract { } None } + + pub fn is_extn_provider(&self) -> Option { + if let RippleContract::ExtnProvider(e) = self { + Some(e.id.to_string()) + } else { + None + } + } } #[derive(Debug, Clone, PartialEq)] diff --git a/core/sdk/src/lib.rs b/core/sdk/src/lib.rs index b2b7f16ea..38576d75b 100644 --- a/core/sdk/src/lib.rs +++ b/core/sdk/src/lib.rs @@ -35,3 +35,7 @@ pub extern crate serde_json; pub extern crate serde_yaml; pub extern crate tokio; pub extern crate uuid; + +pub trait Mockable { + fn mock() -> Self; +} diff --git a/core/sdk/src/utils/mod.rs b/core/sdk/src/utils/mod.rs index 13ca2ba1a..bd05419d5 100644 --- a/core/sdk/src/utils/mod.rs +++ b/core/sdk/src/utils/mod.rs @@ -20,6 +20,7 @@ pub mod error; pub mod extn_utils; pub mod logger; pub mod mock_utils; +pub mod rpc_utils; pub mod serde_utils; pub mod test_utils; pub mod time_utils; diff --git a/core/sdk/src/utils/rpc_utils.rs b/core/sdk/src/utils/rpc_utils.rs new file mode 100644 index 000000000..7f31dab27 --- /dev/null +++ b/core/sdk/src/utils/rpc_utils.rs @@ -0,0 +1,22 @@ +// Copyright 2023 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// + +use jsonrpsee_core::Error; + +pub fn rpc_err(msg: impl Into) -> Error { + Error::Custom(msg.into()) +} diff --git a/device/mock_device/Cargo.toml b/device/mock_device/Cargo.toml new file mode 100644 index 000000000..cf7e68f59 --- /dev/null +++ b/device/mock_device/Cargo.toml @@ -0,0 +1,39 @@ +# Copyright 2023 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +[package] +name = "mock_device" +version = "1.0.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib"] + +[dependencies] +http = "0.2.8" +jsonrpsee = { version = "0.9.0", features = ["macros", "jsonrpsee-core"] } +ripple_sdk = { path = "../../core/sdk" } +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } +tokio-tungstenite = { version = "0.20.0" } +url = "2.2.2" + + +[dev-dependencies] +ripple_tdk = { path = "../../core/tdk" } diff --git a/device/mock_device/README.md b/device/mock_device/README.md new file mode 100644 index 000000000..1ec955388 --- /dev/null +++ b/device/mock_device/README.md @@ -0,0 +1,3 @@ +Refer to the guide in the docs for how to use this extension. + +[Guide](../../docs/mock-device.md) \ No newline at end of file diff --git a/device/mock_device/src/errors.rs b/device/mock_device/src/errors.rs new file mode 100644 index 000000000..5360c510a --- /dev/null +++ b/device/mock_device/src/errors.rs @@ -0,0 +1,129 @@ +// Copyright 2023 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// + +use std::{fmt::Display, path::PathBuf}; + +use crate::mock_data::MockDataError; + +#[derive(Debug, Clone)] +pub enum MockServerWebSocketError { + CantListen, +} + +impl std::error::Error for MockServerWebSocketError {} + +impl Display for MockServerWebSocketError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let msg = match self { + Self::CantListen => "Failed to start TcpListener", + }; + + f.write_str(msg) + } +} + +#[derive(Clone, Debug)] +pub enum MockDeviceError { + BootFailed(BootFailedError), + LoadMockDataFailed(LoadMockDataError), +} + +impl std::error::Error for MockDeviceError {} + +impl Display for MockDeviceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let msg = match self { + Self::BootFailed(reason) => { + format!("Failed to start websocket server. Reason: {reason}") + } + Self::LoadMockDataFailed(reason) => { + format!("Failed to load mock data from file. Reason: {reason}") + } + }; + + f.write_str(msg.as_str()) + } +} + +#[derive(Clone, Debug)] +pub enum BootFailedError { + BadUrlScheme, + BadHostname, + GetPlatformGatewayFailed, + ServerStartFailed(MockServerWebSocketError), +} + +impl Display for BootFailedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let msg = match self { + Self::BadUrlScheme => "The scheme in the URL is invalid. It must be `ws`.".to_owned(), + Self::BadHostname => { + "The hostname in the URL is invalid. It must be `0.0.0.0` or `127.0.0.1`." + .to_owned() + } + Self::GetPlatformGatewayFailed => { + "Failed to get plaftform gateway from the Thunder extension config.".to_owned() + } + Self::ServerStartFailed(err) => { + format!("Failed to start the WebSocket server. Error: {err}") + } + }; + + f.write_str(msg.as_str()) + } +} + +impl From for MockDeviceError { + fn from(value: BootFailedError) -> Self { + Self::BootFailed(value) + } +} + +#[derive(Clone, Debug)] +pub enum LoadMockDataError { + PathDoesNotExist(PathBuf), + FileOpenFailed(PathBuf), + GetSavedDirFailed, + MockDataNotValidJson, + MockDataNotArray, + MockDataError(MockDataError), +} + +impl Display for LoadMockDataError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let msg = match self { + Self::PathDoesNotExist(path) => { + format!("Path does not exist. Path: {}", path.display()) + } + Self::FileOpenFailed(path) => format!("Failed to open file. File: {}", path.display()), + Self::GetSavedDirFailed => "Failed to get SavedDir from config.".to_owned(), + Self::MockDataNotValidJson => "The mock data is not valid JSON.".to_owned(), + Self::MockDataNotArray => "The mock data file root object must be an array.".to_owned(), + Self::MockDataError(err) => { + format!("Failed to parse message in mock data. Error: {err:?}") + } + }; + + f.write_str(msg.as_str()) + } +} + +impl From for MockDeviceError { + fn from(value: LoadMockDataError) -> Self { + Self::LoadMockDataFailed(value) + } +} diff --git a/device/mock_device/src/lib.rs b/device/mock_device/src/lib.rs new file mode 100644 index 000000000..2a87a9ac8 --- /dev/null +++ b/device/mock_device/src/lib.rs @@ -0,0 +1,28 @@ +// Copyright 2023 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// + +pub mod errors; +pub mod mock_config; +pub mod mock_data; +pub mod mock_device_controller; +pub mod mock_device_ffi; +pub mod mock_device_processor; +pub mod mock_server; +pub mod mock_web_socket_server; +#[cfg(test)] +pub(crate) mod test_utils; +pub mod utils; diff --git a/device/mock_device/src/mock-device-openrpc.json b/device/mock_device/src/mock-device-openrpc.json new file mode 100644 index 000000000..0deec0422 --- /dev/null +++ b/device/mock_device/src/mock-device-openrpc.json @@ -0,0 +1,120 @@ +{ + "openrpc": "1.2.4", + "info": { + "title": "Mock Device", + "version": "0.1.0" + }, + "methods": [ + { + "name": "mockdevice.addRequests", + "summary": "Provides a way for test applications to add a request and response", + "params": [ + { + "name": "type", + "schema": { + "type": "object" + } + } + ], + "tags": [ + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:mock:device" + ] + } + ], + "result": { + "name": "result", + "schema": { + "const": null + } + }, + "examples": [ + { + "name": "Set request and response", + "params": [ + ], + "result": { + "name": "defaultResult", + "value": null + } + } + ] + }, + { + "name": "mockdevice.removeRequests", + "summary": "Provides a way for test applications to add a request and response", + "params": [ + { + "name": "type", + "schema": { + "type": "object" + } + } + ], + "tags": [ + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:mock:device" + ] + } + ], + "result": { + "name": "result", + "schema": { + "const": null + } + }, + "examples": [ + { + "name": "Set request and response", + "params": [ + ], + "result": { + "name": "defaultResult", + "value": null + } + } + ] + }, + { + "name": "mockdevice.emitEvent", + "summary": "Provides a way for test applications to add a request and response", + "params": [ + { + "name": "type", + "schema": { + "type": "object" + } + } + ], + "tags": [ + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:mock:device" + ] + } + ], + "result": { + "name": "result", + "schema": { + "const": null + } + }, + "examples": [ + { + "name": "Set request and response", + "params": [ + ], + "result": { + "name": "defaultResult", + "value": null + } + } + ] + } + ] +} \ No newline at end of file diff --git a/device/mock_device/src/mock_config.rs b/device/mock_device/src/mock_config.rs new file mode 100644 index 000000000..628af854d --- /dev/null +++ b/device/mock_device/src/mock_config.rs @@ -0,0 +1,31 @@ +// Copyright 2023 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// + +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct MockConfig { + pub activate_all_plugins: bool, +} + +impl Default for MockConfig { + fn default() -> Self { + Self { + activate_all_plugins: true, + } + } +} diff --git a/device/mock_device/src/mock_data.rs b/device/mock_device/src/mock_data.rs new file mode 100644 index 000000000..ae859f04b --- /dev/null +++ b/device/mock_device/src/mock_data.rs @@ -0,0 +1,323 @@ +// Copyright 2023 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// + +use ripple_sdk::log::{debug, error}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{collections::HashMap, fmt::Display}; + +use crate::{ + errors::{LoadMockDataError, MockDeviceError}, + mock_server::{MessagePayload, PayloadType, PayloadTypeError}, + mock_web_socket_server::ThunderRegisterParams, +}; + +pub type MockData = HashMap>; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ParamResponse { + pub params: Option, + pub result: Option, + pub error: Option, + pub events: Option>, +} + +#[derive(Debug)] +pub struct ResponseSink { + pub delay: u64, + pub data: Value, +} + +impl ParamResponse { + pub fn get_key(&self, key: &Value) -> Option { + match &self.params { + Some(v) => { + if v.eq(key) { + return Some(self.clone()); + } + None + } + None => Some(self.clone()), + } + } + pub fn get_notification_id(&self) -> Option { + if let Some(params) = &self.params { + if let Some(event) = params.get("event") { + if let Some(id) = params.get("id") { + return Some(format!( + "{}.{}", + id.to_string().replace('\"', ""), + event.to_string().replace('\"', "") + )); + } + } + } + None + } + + pub fn get_all( + &self, + id: Option, + thunder_response: Option, + ) -> Vec { + let mut sink_responses = Vec::new(); + if let Some(e) = self.error.clone() { + sink_responses.push(ResponseSink { + delay: 0, + data: json!({"jsonrpc": "2.0", "id": id, "error": [e]}), + }); + } else if let Some(v) = self.result.clone() { + sink_responses.push(ResponseSink { + delay: 0, + data: json!({"jsonrpc": "2.0", "id": id, "result": v}), + }); + + if let Some(events) = &self.events { + let notif_id = if let Some(t) = thunder_response { + Some(format!("{}.{}", t.id, t.event)) + } else { + self.get_notification_id() + }; + + error!("Getting notif id {:?}", notif_id); + for event in events { + sink_responses.push(ResponseSink { + delay: event.delay.unwrap_or(0), + data: json!({"jsonrpc": "2.0", "method": notif_id, "params": event.data.clone()}) + }) + } + } + } else { + sink_responses.push(ResponseSink { + delay: 0, + data: json!({"jsonrpc": "2.0", "id": id, "result": null}), + }); + } + debug!("Total sink responses {:?}", sink_responses); + sink_responses + } +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct EventValue { + pub delay: Option, + pub data: Value, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum MockDataError { + NotAnObject, + MissingTypeProperty, + MissingBodyProperty, + PayloadTypeError(PayloadTypeError), + MissingRequestField, + MissingResponseField, + FailedToCreateKey(Value), +} + +impl std::error::Error for MockDataError {} + +impl Display for MockDataError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let msg = match self { + Self::MissingTypeProperty => "Message must have a type property.".to_owned(), + Self::MissingBodyProperty => "Message must have a body property.".to_owned(), + Self::PayloadTypeError(err) => format!("{err}"), + Self::FailedToCreateKey(body) => { + format!("Unable to create a key for message. Message body: {body}") + } + Self::MissingRequestField => "The request field is missing.".to_owned(), + Self::MissingResponseField => "The response field is missing.".to_owned(), + Self::NotAnObject => "Payload must be an object.".to_owned(), + }; + + f.write_str(msg.as_str()) + } +} + +impl From for MockDeviceError { + fn from(err: MockDataError) -> Self { + MockDeviceError::LoadMockDataFailed(LoadMockDataError::MockDataError(err)) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct MockDataMessage { + pub message_type: PayloadType, + pub body: Value, +} + +impl From for MockDataMessage { + fn from(value: MessagePayload) -> Self { + Self { + message_type: value.payload_type, + body: value.body, + } + } +} + +impl TryFrom<&Value> for MockDataMessage { + type Error = MockDataError; + + fn try_from(value: &Value) -> Result { + let message_type = value + .get("type") + .and_then(|v| v.as_str()) + .ok_or(MockDataError::MissingTypeProperty)?; + let message_body = value + .get("body") + .ok_or(MockDataError::MissingBodyProperty)?; + + Ok(MockDataMessage { + message_type: message_type + .try_into() + .map_err(MockDataError::PayloadTypeError)?, + body: message_body.clone(), + }) + } +} + +// TODO: should MockDataMessage be a trait? +impl MockDataMessage { + pub fn is_json(&self) -> bool { + matches!(self.message_type, PayloadType::Json) + } + + pub fn is_jsonrpc(&self) -> bool { + matches!(self.message_type, PayloadType::JsonRpc) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_param_response_get_key() { + let response = ParamResponse { + result: None, + error: None, + events: None, + params: None, + }; + assert!(response.get_key(&Value::Null).is_some()); + let response = ParamResponse { + result: None, + error: None, + events: None, + params: Some(Value::String("Some".to_owned())), + }; + assert!(response.get_key(&Value::Null).is_none()); + assert!(response + .get_key(&Value::String("Some".to_owned())) + .is_some()); + } + + #[test] + fn test_param_response_get_notif_id() { + let response = ParamResponse { + result: None, + error: None, + events: None, + params: None, + }; + assert!(response.get_notification_id().is_none()); + let response = ParamResponse { + result: None, + error: None, + events: None, + params: Some(Value::String("Some".to_owned())), + }; + assert!(response.get_notification_id().is_none()); + + let response = ParamResponse { + result: None, + error: None, + events: None, + params: Some(json!({ + "event": "SomeEvent", + "id": "SomeId" + })), + }; + + assert!(response + .get_notification_id() + .unwrap() + .eq("SomeId.SomeEvent")); + } + + #[test] + fn test_get_all() { + let pr = ParamResponse { + result: None, + error: Some(json!({"code": -32010, "message": "Error Message"})), + events: None, + params: None, + }; + let response = pr.get_all(Some(0), None)[0] + .data + .get("error") + .unwrap() + .as_array() + .unwrap()[0] + .get("code") + .unwrap() + .as_i64() + .unwrap(); + assert!(response.eq(&-32010)); + + let pr = ParamResponse { + result: Some(json!({"code": 0})), + error: None, + events: Some(vec![EventValue { + delay: Some(0), + data: json!({"event": 0}), + }]), + params: None, + }; + + let response = pr.get_all(Some(0), None)[0] + .data + .get("result") + .unwrap() + .get("code") + .unwrap() + .as_i64() + .unwrap(); + assert!(response.eq(&0)); + + let event_value = pr.get_all(Some(1), None)[1] + .data + .get("params") + .unwrap() + .get("event") + .unwrap() + .as_i64() + .unwrap(); + assert!(event_value.eq(&0)); + let params = ThunderRegisterParams { + event: "SomeEvent".to_owned(), + id: "SomeId".to_owned(), + }; + if let Some(v) = pr.get_all(Some(1), Some(params)).get(1) { + let event_value = v.data.get("method").unwrap().as_str().unwrap(); + assert!(event_value.eq("SomeId.SomeEvent")); + } else { + panic!("Failure in get all with thunder register params") + } + } +} diff --git a/device/mock_device/src/mock_device_controller.rs b/device/mock_device/src/mock_device_controller.rs new file mode 100644 index 000000000..61b6f698c --- /dev/null +++ b/device/mock_device/src/mock_device_controller.rs @@ -0,0 +1,168 @@ +// Copyright 2023 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// + +use std::fmt::Display; + +use crate::{ + mock_data::MockData, + mock_device_ffi::EXTN_NAME, + mock_server::{EmitEventParams, MockServerRequest}, +}; +use jsonrpsee::{core::RpcResult, proc_macros::rpc}; +use ripple_sdk::{ + api::gateway::rpc_gateway_api::CallContext, + async_trait::async_trait, + extn::{ + client::extn_client::ExtnClient, + extn_id::{ExtnClassId, ExtnId, ExtnProviderRequest, ExtnProviderResponse}, + }, + log::debug, + tokio::runtime::Runtime, + utils::{error::RippleError, rpc_utils::rpc_err}, +}; + +#[derive(Debug, Clone)] +enum MockDeviceControllerError { + RequestFailed(RippleError), + ExtnCommunicationFailed, +} + +impl std::error::Error for MockDeviceControllerError {} + +impl Display for MockDeviceControllerError { + fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::result::Result<(), ::std::fmt::Error> { + let msg = match self.clone() { + MockDeviceControllerError::RequestFailed(err) => { + format!("Failed to complete the request. RippleError {err:?}") + } + MockDeviceControllerError::ExtnCommunicationFailed => { + "Failed to communicate with the Mock Device extension".to_owned() + } + }; + + f.write_str(msg.as_str()) + } +} + +impl From for String { + fn from(value: MockDeviceControllerError) -> Self { + value.to_string() + } +} + +#[rpc(server)] +pub trait MockDeviceController { + #[method(name = "mockdevice.emitEvent")] + async fn emit_event( + &self, + ctx: CallContext, + req: EmitEventParams, + ) -> RpcResult; + + #[method(name = "mockdevice.addRequests")] + async fn add_request_responses( + &self, + ctx: CallContext, + req: MockData, + ) -> RpcResult; + + #[method(name = "mockdevice.removeRequests")] + async fn remove_requests( + &self, + ctx: CallContext, + req: MockData, + ) -> RpcResult; +} + +pub struct MockDeviceController { + client: ExtnClient, + rt: Runtime, + id: ExtnId, +} + +impl MockDeviceController { + pub fn new(client: ExtnClient) -> MockDeviceController { + MockDeviceController { + client, + rt: Runtime::new().unwrap(), + id: ExtnId::new_channel(ExtnClassId::Device, EXTN_NAME.into()), + } + } + + async fn request( + &self, + request: MockServerRequest, + ) -> Result { + debug!("request={request:?}"); + let client = self.client.clone(); + let request = ExtnProviderRequest { + value: serde_json::to_value(request).unwrap(), + id: self.id.clone(), + }; + self.rt + .spawn(async move { + client + .standalone_request(request, 5000) + .await + .map_err(MockDeviceControllerError::RequestFailed) + }) + .await + .map_err(|_| MockDeviceControllerError::ExtnCommunicationFailed)? + } +} + +#[async_trait] +impl MockDeviceControllerServer for MockDeviceController { + async fn add_request_responses( + &self, + _ctx: CallContext, + req: MockData, + ) -> RpcResult { + let res = self + .request(MockServerRequest::AddRequestResponse(req)) + .await + .map_err(rpc_err)?; + + Ok(res) + } + + async fn remove_requests( + &self, + _ctx: CallContext, + req: MockData, + ) -> RpcResult { + let res = self + .request(MockServerRequest::RemoveRequestResponse(req)) + .await + .map_err(rpc_err)?; + + Ok(res) + } + + async fn emit_event( + &self, + _ctx: CallContext, + req: EmitEventParams, + ) -> RpcResult { + let res = self + .request(MockServerRequest::EmitEvent(req)) + .await + .map_err(rpc_err)?; + + Ok(res) + } +} diff --git a/device/mock_device/src/mock_device_ffi.rs b/device/mock_device/src/mock_device_ffi.rs new file mode 100644 index 000000000..8a64ebabb --- /dev/null +++ b/device/mock_device/src/mock_device_ffi.rs @@ -0,0 +1,183 @@ +// Copyright 2023 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// + +use jsonrpsee::core::server::rpc_module::Methods; +use ripple_sdk::{ + api::status_update::ExtnStatus, + async_channel::Receiver as CReceiver, + export_channel_builder, export_extn_metadata, export_jsonrpc_extn_builder, + extn::{ + client::{extn_client::ExtnClient, extn_sender::ExtnSender}, + extn_id::{ExtnClassId, ExtnId, ExtnProviderAdjective}, + ffi::{ + ffi_channel::{ExtnChannel, ExtnChannelBuilder}, + ffi_jsonrpsee::JsonRpseeExtnBuilder, + ffi_library::{CExtnMetadata, ExtnMetadata, ExtnSymbolMetadata}, + ffi_message::CExtnMessage, + }, + }, + framework::ripple_contract::{ContractFulfiller, RippleContract}, + log::{debug, info}, + semver::Version, + tokio::{self, runtime::Runtime}, + utils::{error::RippleError, logger::init_logger}, +}; + +use crate::{ + mock_device_controller::{MockDeviceController, MockDeviceControllerServer}, + mock_device_processor::MockDeviceProcessor, + utils::boot_ws_server, +}; + +pub const EXTN_NAME: &str = "mock_device"; + +fn init_library() -> CExtnMetadata { + let _ = init_logger(EXTN_NAME.into()); + let id = ExtnId::new_channel(ExtnClassId::Device, EXTN_NAME.into()); + let mock_device_channel = ExtnSymbolMetadata::get( + id.clone(), + ContractFulfiller::new(vec![RippleContract::ExtnProvider(ExtnProviderAdjective { + id, + })]), + Version::new(1, 0, 0), + ); + let mock_device_extn = ExtnSymbolMetadata::get( + ExtnId::new_extn(ExtnClassId::Jsonrpsee, EXTN_NAME.into()), + ContractFulfiller::new(vec![RippleContract::JsonRpsee]), + Version::new(1, 0, 0), + ); + + debug!("Returning mock_device metadata builder"); + let extn_metadata = ExtnMetadata { + name: EXTN_NAME.into(), + symbols: vec![mock_device_channel, mock_device_extn], + }; + + extn_metadata.into() +} + +export_extn_metadata!(CExtnMetadata, init_library); + +fn start_launcher(sender: ExtnSender, receiver: CReceiver) { + let _ = init_logger(EXTN_NAME.into()); + info!("Starting mock device channel"); + let runtime = Runtime::new().unwrap(); + let mut client = ExtnClient::new(receiver, sender); + runtime.block_on(async move { + let client_c = client.clone(); + tokio::spawn(async move { + match boot_ws_server(client.clone()).await { + Ok(server) => { + client.add_request_processor(MockDeviceProcessor::new(client.clone(), server)) + } + Err(err) => panic!("websocket server failed to start. {}", err), + }; + + // Lets Main know that the mock_device channel is ready + let _ = client.event(ExtnStatus::Ready); + }); + client_c.initialize().await; + }); +} + +fn build(extn_id: String) -> Result, RippleError> { + if let Ok(id) = ExtnId::try_from(extn_id) { + let current_id = ExtnId::new_channel(ExtnClassId::Device, EXTN_NAME.into()); + + if id.eq(¤t_id) { + Ok(Box::new(ExtnChannel { + start: start_launcher, + })) + } else { + Err(RippleError::ExtnError) + } + } else { + Err(RippleError::InvalidInput) + } +} + +fn init_extn_builder() -> ExtnChannelBuilder { + ExtnChannelBuilder { + build, + service: EXTN_NAME.into(), + } +} + +export_channel_builder!(ExtnChannelBuilder, init_extn_builder); + +fn get_rpc_extns(sender: ExtnSender, receiver: CReceiver) -> Methods { + let mut methods = Methods::new(); + let client = ExtnClient::new(receiver, sender); + let _ = methods.merge(MockDeviceController::new(client).into_rpc()); + + methods +} + +fn get_extended_capabilities() -> Option { + Some(String::from(std::include_str!( + "./mock-device-openrpc.json" + ))) +} + +fn init_jsonrpsee_builder() -> JsonRpseeExtnBuilder { + JsonRpseeExtnBuilder { + get_extended_capabilities, + build: get_rpc_extns, + service: EXTN_NAME.into(), + } +} + +export_jsonrpc_extn_builder!(JsonRpseeExtnBuilder, init_jsonrpsee_builder); + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::test_utils::extn_sender_web_socket_mock_server; + + use super::*; + + #[test] + fn test_init_library() { + assert_eq!( + init_library(), + CExtnMetadata { + name: "mock_device".to_owned(), + metadata: json!([ + {"fulfills": json!([json!({"extn_provider": "ripple:channel:device:mock_device"}).to_string()]).to_string(), "id": "ripple:channel:device:mock_device", "required_version": "1.0.0"}, + {"fulfills": json!([json!("json_rpsee").to_string()]).to_string(), "id": "ripple:extn:jsonrpsee:mock_device", "required_version": "1.0.0"} + ]) + .to_string() + } + ) + } + + #[ignore] + #[test] + fn test_init_jsonrpsee_builder() { + let builder = init_jsonrpsee_builder(); + + let (sender, receiver) = extn_sender_web_socket_mock_server(); + let methods = (builder.build)(sender, receiver); + + assert_eq!(builder.service, "mock_device".to_owned()); + assert!((builder.get_extended_capabilities)().is_none()); + assert!(methods.method("mockdevice.addRequestResponse").is_some()); + assert!(methods.method("mockdevice.removeRequest").is_some()); + assert!(methods.method("mockdevice.emitEvent").is_some()); + } +} diff --git a/device/mock_device/src/mock_device_processor.rs b/device/mock_device/src/mock_device_processor.rs new file mode 100644 index 000000000..d88e9a1d7 --- /dev/null +++ b/device/mock_device/src/mock_device_processor.rs @@ -0,0 +1,191 @@ +// Copyright 2023 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// +use ripple_sdk::{ + async_trait::async_trait, + extn::{ + client::{ + extn_client::ExtnClient, + extn_processor::{ + DefaultExtnStreamer, ExtnRequestProcessor, ExtnStreamProcessor, ExtnStreamer, + }, + }, + extn_client_message::{ExtnMessage, ExtnResponse}, + extn_id::{ExtnClassId, ExtnId, ExtnProviderAdjective, ExtnProviderRequest}, + }, + framework::ripple_contract::RippleContract, + log::{debug, error}, + tokio::sync::mpsc::{Receiver, Sender}, + utils::error::RippleError, +}; +use std::sync::Arc; + +use crate::{ + mock_device_ffi::EXTN_NAME, + mock_server::{ + AddRequestResponseResponse, EmitEventResponse, MockServerRequest, MockServerResponse, + RemoveRequestResponse, + }, + mock_web_socket_server::MockWebSocketServer, +}; + +#[derive(Debug, Clone)] +pub struct MockDeviceState { + client: ExtnClient, + server: Arc, +} + +impl MockDeviceState { + fn new(client: ExtnClient, server: Arc) -> Self { + Self { client, server } + } +} + +pub struct MockDeviceProcessor { + state: MockDeviceState, + streamer: DefaultExtnStreamer, +} + +impl MockDeviceProcessor { + pub fn new(client: ExtnClient, server: Arc) -> MockDeviceProcessor { + MockDeviceProcessor { + state: MockDeviceState::new(client, server), + streamer: DefaultExtnStreamer::new(), + } + } + + async fn respond(client: ExtnClient, req: ExtnMessage, resp: MockServerResponse) -> bool { + let resp = client + .clone() + .respond( + req, + ExtnResponse::Value(serde_json::to_value(resp).unwrap()), + ) + .await; + + match resp { + Ok(_) => true, + Err(err) => { + error!("{err:?}"); + false + } + } + } +} + +impl ExtnStreamProcessor for MockDeviceProcessor { + type STATE = MockDeviceState; + type VALUE = ExtnProviderRequest; + + fn get_state(&self) -> Self::STATE { + self.state.clone() + } + + fn receiver(&mut self) -> Receiver { + self.streamer.receiver() + } + + fn sender(&self) -> Sender { + self.streamer.sender() + } + + fn contract(&self) -> ripple_sdk::framework::ripple_contract::RippleContract { + RippleContract::ExtnProvider(ExtnProviderAdjective { + id: ExtnId::new_channel(ExtnClassId::Device, EXTN_NAME.into()), + }) + } +} + +#[async_trait] +impl ExtnRequestProcessor for MockDeviceProcessor { + fn get_client(&self) -> ExtnClient { + self.state.client.clone() + } + + async fn process_request( + state: Self::STATE, + extn_request: ExtnMessage, + extracted_message: Self::VALUE, + ) -> bool { + debug!("extn_request={extn_request:?}, extracted_message={extracted_message:?}"); + if let Ok(message) = serde_json::from_value::(extracted_message.value) { + match message { + MockServerRequest::AddRequestResponse(params) => { + let resp = match state.server.add_request_response_v2(params).await { + Ok(_) => AddRequestResponseResponse { + success: true, + error: None, + }, + Err(err) => AddRequestResponseResponse { + success: false, + error: Some(err.to_string()), + }, + }; + Self::respond( + state.client.clone(), + extn_request, + MockServerResponse::AddRequestResponse(resp), + ) + .await + } + MockServerRequest::RemoveRequestResponse(params) => { + let resp = match state.server.remove_request_response_v2(params).await { + Ok(_) => RemoveRequestResponse { + success: true, + error: None, + }, + Err(err) => RemoveRequestResponse { + success: false, + error: Some(err.to_string()), + }, + }; + Self::respond( + state.client.clone(), + extn_request, + MockServerResponse::RemoveRequestResponse(resp), + ) + .await + } + MockServerRequest::EmitEvent(params) => { + state + .server + .emit_event(¶ms.event.body, params.event.delay) + .await; + + Self::respond( + state.client.clone(), + extn_request, + MockServerResponse::EmitEvent(EmitEventResponse { success: true }), + ) + .await + } + } + } else { + Self::handle_error(state.client, extn_request, RippleError::ProcessorError).await + } + } +} + +#[cfg(test)] +mod tests { + #[test] + #[should_panic] + fn test_add_request_response() { + todo!( + "currently unable to test this without a testing solution so ExtnClient interactions" + ); + } +} diff --git a/device/mock_device/src/mock_server.rs b/device/mock_device/src/mock_server.rs new file mode 100644 index 000000000..476742744 --- /dev/null +++ b/device/mock_device/src/mock_server.rs @@ -0,0 +1,175 @@ +// Copyright 2023 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// + +use std::fmt::Display; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::mock_data::MockData; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub enum PayloadTypeError { + InvalidMessageType, +} + +impl Display for PayloadTypeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidMessageType => { + f.write_str("Invalid message type. Possible values are: json, jsonrpc") + } + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub enum PayloadType { + #[serde(rename = "json")] + Json, + #[serde(rename = "jsonrpc")] + JsonRpc, +} + +impl Display for PayloadType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&String::from(self)) + } +} + +impl From<&PayloadType> for String { + fn from(val: &PayloadType) -> Self { + match val { + PayloadType::Json => "json".to_string(), + PayloadType::JsonRpc => "jsonrpc".to_string(), + } + } +} + +impl TryFrom<&str> for PayloadType { + type Error = PayloadTypeError; + + fn try_from(val: &str) -> Result { + match val { + "json" => Ok(PayloadType::Json), + "jsonrpc" => Ok(PayloadType::JsonRpc), + _ => Err(PayloadTypeError::InvalidMessageType), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct MessagePayload { + /// The type of payload data + #[serde(rename = "type")] + pub payload_type: PayloadType, + /// The body of the request + pub body: Value, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EventPayload { + // TODO: wrap around MessagePayload + /// The body of the event + pub body: Value, + /// The number of msecs before the event should be emitted + pub delay: u64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub enum MockServerRequest { + EmitEvent(EmitEventParams), + AddRequestResponse(MockData), + RemoveRequestResponse(MockData), +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub enum MockServerResponse { + AddRequestResponse(AddRequestResponseResponse), + EmitEvent(EmitEventResponse), + RemoveRequestResponse(RemoveRequestResponse), +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AddRequestResponseParams { + pub request: MessagePayload, + pub responses: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct AddRequestResponseResponse { + pub success: bool, + pub error: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RemoveRequestParams { + pub request: MessagePayload, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct RemoveRequestResponse { + pub success: bool, + pub error: Option, +} + +// TODO: add a clear all mock data request + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EmitEventParams { + pub event: EventPayload, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct EmitEventResponse { + pub success: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_message_type_try_from_str_json() { + assert_eq!(PayloadType::try_from("json"), Ok(PayloadType::Json)); + } + + #[test] + fn test_message_type_try_from_str_jsonrpc() { + assert_eq!(PayloadType::try_from("jsonrpc"), Ok(PayloadType::JsonRpc)); + } + + #[test] + fn test_message_type_try_from_str_err() { + assert_eq!( + PayloadType::try_from("unknown"), + Err(PayloadTypeError::InvalidMessageType) + ); + } + + #[test] + fn test_message_type_to_string_json() { + assert_eq!(PayloadType::Json.to_string(), "json".to_owned()); + assert_eq!(String::from(&PayloadType::Json), "json".to_owned()); + } + + #[test] + fn test_message_type_to_string_jsonrpc() { + assert_eq!(PayloadType::JsonRpc.to_string(), "jsonrpc".to_owned()); + assert_eq!(String::from(&PayloadType::JsonRpc), "jsonrpc".to_owned()); + } +} diff --git a/device/mock_device/src/mock_web_socket_server.rs b/device/mock_device/src/mock_web_socket_server.rs new file mode 100644 index 000000000..fc4569794 --- /dev/null +++ b/device/mock_device/src/mock_web_socket_server.rs @@ -0,0 +1,614 @@ +// Copyright 2023 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// +use std::{ + collections::HashMap, + net::SocketAddr, + sync::{Arc, RwLock}, + time::Duration, +}; + +use http::{HeaderMap, StatusCode}; +use ripple_sdk::{ + api::gateway::rpc_gateway_api::JsonRpcApiRequest, + futures::{stream::SplitSink, SinkExt, StreamExt}, + log::{debug, error, warn}, + tokio::{ + self, + net::{TcpListener, TcpStream}, + sync::Mutex, + }, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio_tungstenite::{ + accept_hdr_async, + tungstenite::{handshake, Error, Message, Result}, + WebSocketStream, +}; + +use crate::{ + errors::MockServerWebSocketError, + mock_config::MockConfig, + mock_data::{MockData, MockDataError, ParamResponse, ResponseSink}, + utils::is_value_jsonrpc, +}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct ThunderRegisterParams { + pub event: String, + pub id: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct WsServerParameters { + path: Option, + + headers: Option, + + query_params: Option>, + + port: Option, +} + +impl WsServerParameters { + pub fn new() -> Self { + Self { + path: None, + headers: None, + query_params: None, + port: None, + } + } + pub fn path(&mut self, path: &str) -> &mut Self { + self.path = Some(path.into()); + + self + } + pub fn headers(&mut self, headers: HeaderMap) -> &mut Self { + self.headers = Some(headers); + + self + } + pub fn query_params(&mut self, query_params: HashMap) -> &mut Self { + self.query_params = Some(query_params); + + self + } + pub fn port(&mut self, port: u16) -> &mut Self { + self.port = Some(port); + + self + } +} + +impl Default for WsServerParameters { + fn default() -> Self { + Self::new() + } +} + +type WSConnection = Arc, Message>>>>; + +#[derive(Debug)] +pub struct MockWebSocketServer { + mock_data_v2: Arc>, + + listener: TcpListener, + + conn_path: String, + + conn_headers: HeaderMap, + + conn_query_params: HashMap, + + port: u16, + + connected_peer_sinks: WSConnection, + + config: MockConfig, +} + +impl MockWebSocketServer { + pub async fn new( + mock_data_v2: MockData, + server_config: WsServerParameters, + config: MockConfig, + ) -> Result { + let listener = Self::create_listener(server_config.port.unwrap_or(0)).await?; + let port = listener + .local_addr() + .map_err(|_| MockServerWebSocketError::CantListen)? + .port(); + + Ok(Self { + listener, + port, + conn_path: server_config.path.unwrap_or_else(|| "/".to_string()), + conn_headers: server_config.headers.unwrap_or_default(), + conn_query_params: server_config.query_params.unwrap_or_default(), + connected_peer_sinks: Arc::new(Mutex::new(HashMap::new())), + config, + mock_data_v2: Arc::new(RwLock::new( + mock_data_v2 + .into_iter() + .map(|(k, v)| (k.to_lowercase(), v)) + .collect(), + )), + }) + } + + pub fn port(&self) -> u16 { + self.port + } + + async fn create_listener(port: u16) -> Result { + let addr: SocketAddr = format!("0.0.0.0:{}", port).parse().unwrap(); + let listener = TcpListener::bind(&addr) + .await + .map_err(|_| MockServerWebSocketError::CantListen)?; + debug!("Listening on: {:?}", listener.local_addr().unwrap()); + + Ok(listener) + } + + pub fn into_arc(self) -> Arc { + Arc::new(self) + } + + pub async fn start_server(self: Arc) { + debug!("Waiting for connections"); + + while let Ok((stream, peer_addr)) = self.listener.accept().await { + let server = self.clone(); + tokio::spawn(async move { + server.accept_connection(peer_addr, stream).await; + }); + } + + debug!("Shutting down"); + } + + async fn accept_connection(&self, peer: SocketAddr, stream: TcpStream) { + debug!("Peer address: {}", peer); + let connection = self.handle_connection(peer, stream).await; + + if let Err(e) = connection { + match e { + Error::ConnectionClosed | Error::Protocol(_) | Error::Utf8 => (), + err => error!("Error processing connection: {:?}", err), + } + } + } + + async fn handle_connection(&self, peer: SocketAddr, stream: TcpStream) -> Result<()> { + let callback = |request: &handshake::client::Request, + mut response: handshake::server::Response| { + let path = request.uri().path(); + if path != self.conn_path { + *response.status_mut() = StatusCode::NOT_FOUND; + debug!("Connection response {:?}", response); + } + + if !self.conn_headers.iter().all(|(header_name, header_value)| { + request.headers().get(header_name) == Some(header_value) + }) { + *response.status_mut() = StatusCode::BAD_REQUEST; + error!("Incompatible headers. Headers required by server: {:?}. Headers sent in request: {:?}", self.conn_headers, request.headers()); + debug!("Connection response {:?}", response); + } + + let request_query = + url::form_urlencoded::parse(request.uri().query().unwrap_or("").as_bytes()) + .into_owned() + .collect::>(); + + let eq_num_params = self.conn_query_params.len() == request_query.len(); + let all_params_match = + self.conn_query_params + .iter() + .all(|(param_name, param_value)| { + request_query.get(param_name) == Some(param_value) + }); + + if !(eq_num_params && all_params_match) { + *response.status_mut() = StatusCode::BAD_REQUEST; + error!("Incompatible query params. Params required by server: {:?}. Params sent in request: {:?}", self.conn_query_params, request.uri().query()); + debug!("Connection response {:?}", response); + } + + Ok(response) + }; + let ws_stream = accept_hdr_async(stream, callback) + .await + .expect("Failed to accept"); + + let (send, mut recv) = ws_stream.split(); + + debug!("New WebSocket connection: {peer}"); + + self.add_connected_peer(&peer, send).await; + + while let Some(msg) = recv.next().await { + debug!("incoming message"); + let msg = msg?; + debug!("Message: {:?}", msg); + + if msg.is_close() { + break; + } + + if msg.is_text() || msg.is_binary() { + let msg = msg.to_string(); + let request_message = match serde_json::from_str::(msg.as_str()).ok() { + Some(key) => key, + None => { + warn!("Request is not valid JSON. Request: {msg}"); + continue; + } + }; + + debug!("Parsed message: {:?}", request_message); + + let responses = match self.find_responses(request_message).await { + Some(value) => value, + None => continue, + }; + let connected_peer = self.connected_peer_sinks.clone(); + tokio::spawn(async move { + if let Err(e) = + Self::send_to_sink(connected_peer, &peer.to_string(), responses).await + { + error!("Error sending data back to sink {}", e.to_string()); + } + }); + } + } + + debug!("Connection dropped peer={peer}"); + self.remove_connected_peer(&peer).await; + + Ok(()) + } + + async fn send_to_sink( + connection: WSConnection, + peer: &str, + responses: Vec, + ) -> Result<()> { + let mut clients = connection.lock().await; + let sink = clients.get_mut(peer); + if let Some(sink) = sink { + for resp in responses { + let response = resp.data.to_string(); + if resp.delay > 0 { + tokio::time::sleep(Duration::from_millis(resp.delay)).await + } + if let Err(e) = sink.send(Message::Text(response.clone())).await { + error!("Error sending response. resp={e:?}"); + } else { + debug!("sent response. resp={response:?}"); + } + } + } else { + error!("No sink found for peer={peer:?}"); + } + Ok(()) + } + + async fn find_responses(&self, request_message: Value) -> Option> { + debug!( + "is value json rpc {} {}", + request_message, + is_value_jsonrpc(&request_message) + ); + if let Ok(request) = serde_json::from_value::(request_message.clone()) { + if let Some(id) = request.id { + debug!("{}", self.config.activate_all_plugins); + if self.config.activate_all_plugins + && request.method.contains("Controller.1.status") + { + return Some(vec![ResponseSink { + delay: 0, + data: json!({"jsonrpc": "2.0", "id": id, "result": [{"state": "activated"}]}), + }]); + } else if let Some(v) = self.responses_for_key_v2(&request) { + if v.params.is_some() { + if let Ok(t) = serde_json::from_value::( + v.clone().params.unwrap(), + ) { + return Some(v.get_all(Some(id), Some(t))); + } + } + + return Some(v.get_all(Some(id), None)); + } + return Some(vec![ResponseSink { + delay: 0, + data: json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32001, "message":"not found"}}), + }]); + } else { + error!("Failed to get id from request {:?}", request_message); + } + } else { + error!( + "Failed to parse into a json rpc request {:?}", + request_message + ); + } + + None + } + + fn responses_for_key_v2(&self, req: &JsonRpcApiRequest) -> Option { + let mock_data = self.mock_data_v2.read().unwrap(); + if let Some(v) = mock_data.get(&req.method.to_lowercase()).cloned() { + if v.len() == 1 { + return v.first().cloned(); + } else if let Some(params) = &req.params { + for response in v { + if response.get_key(params).is_some() { + return Some(response); + } + } + } + } + None + } + + async fn add_connected_peer( + &self, + peer: &SocketAddr, + sink: SplitSink, Message>, + ) { + let mut peers = self.connected_peer_sinks.lock().await; + peers.insert(peer.to_string(), sink); + } + + async fn remove_connected_peer(&self, peer: &SocketAddr) { + let mut peers = self.connected_peer_sinks.lock().await; + let _ = peers.remove(&peer.to_string()); + } + + pub async fn add_request_response_v2(&self, request: MockData) -> Result<(), MockDataError> { + let mut mock_data = self.mock_data_v2.write().unwrap(); + let lower_key_mock_data: MockData = request + .into_iter() + .map(|(k, v)| (k.to_lowercase(), v)) + .collect(); + mock_data.extend(lower_key_mock_data); + Ok(()) + } + + pub async fn remove_request_response_v2(&self, request: MockData) -> Result<(), MockDataError> { + let mut mock_data = self.mock_data_v2.write().unwrap(); + for (cleanup_key, cleanup_params) in request { + if let Some(v) = mock_data.remove(&cleanup_key.to_lowercase()) { + let mut new_param_response = Vec::new(); + let mut updated = false; + for cleanup_param in cleanup_params { + if let Some(params) = cleanup_param.params { + for current_params in &v { + if current_params.get_key(¶ms).is_none() { + new_param_response.push(current_params.clone()); + } else if !updated { + updated = true; + } + } + } else { + error!("cleanup Params missing") + } + } + if updated && !new_param_response.is_empty() { + let _ = mock_data.insert(cleanup_key, new_param_response); + } else { + let _ = mock_data.insert(cleanup_key, v); + } + } else { + error!("Couldnt find the data in mock") + } + } + Ok(()) + } + + pub async fn emit_event(self: Arc, event: &Value, delay: u64) { + let mut peers = self.connected_peer_sinks.lock().await; + let event_value = event.to_string(); + let mut new_peers = HashMap::new(); + if delay > 0 { + tokio::time::sleep(Duration::from_millis(delay)).await + } + let v = peers.keys().len(); + for (k, mut sink) in peers.drain().take(v) { + if let Err(e) = sink.send(Message::Text(event_value.clone())).await { + error!("Error sending response. resp={e:?}"); + } else { + debug!("sent response. resp={event_value:?}"); + } + new_peers.insert(k, sink); + } + peers.extend(new_peers); + //unimplemented!("Emit event functionality has not yet been implemented {event} {delay}"); + } +} + +#[cfg(test)] +mod tests { + use ripple_sdk::tokio::time::{self, error::Elapsed, Duration}; + + use super::*; + + fn json_response_validator(lhs: &Message, rhs: &Value) -> bool { + if let Message::Text(t) = lhs { + if let Ok(v) = serde_json::from_str::(t) { + println!("{:?} = {:?}", v, rhs); + return v.eq(rhs); + } + } + + false + } + + async fn start_server(mock_data: MockData) -> Arc { + let server = MockWebSocketServer::new( + mock_data, + WsServerParameters::default(), + MockConfig::default(), + ) + .await + .expect("Unable to start server") + .into_arc(); + + tokio::spawn(server.clone().start_server()); + + server + } + + async fn request_response_with_timeout( + server: Arc, + request: Message, + ) -> Result>, Elapsed> { + let (client, _) = + tokio_tungstenite::connect_async(format!("ws://0.0.0.0:{}", server.port())) + .await + .expect("Unable to connect to WS server"); + + let (mut send, mut receive) = client.split(); + + send.send(request).await.expect("Failed to send message"); + + time::timeout(Duration::from_secs(1), receive.next()).await + } + + fn get_mock_data(value: Value) -> MockData { + serde_json::from_value(value).unwrap() + } + + #[test] + fn test_ws_server_parameters_new() { + let params = WsServerParameters::new(); + let params_default = WsServerParameters::default(); + + assert!(params.headers.is_none()); + assert!(params.path.is_none()); + assert!(params.port.is_none()); + assert!(params.query_params.is_none()); + assert_eq!(params, params_default); + } + + #[test] + fn test_ws_server_parameters_props() { + let mut params = WsServerParameters::new(); + let headers: HeaderMap = { + let hm = HashMap::from([("Sec-WebSocket-Protocol".to_owned(), "jsonrpc".to_owned())]); + (&hm).try_into().expect("valid headers") + }; + let qp = HashMap::from([("appId".to_owned(), "test".to_owned())]); + params + .headers(headers.clone()) + .port(16789) + .path("/some/path") + .query_params(qp.clone()); + + assert_eq!(params.headers, Some(headers)); + assert_eq!(params.port, Some(16789)); + assert_eq!(params.path, Some("/some/path".to_owned())); + assert_eq!(params.query_params, Some(qp)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_start_server() { + let mock_data = HashMap::default(); + let server = start_server(mock_data).await; + + let _ = tokio_tungstenite::connect_async(format!("ws://0.0.0.0:{}", server.port())) + .await + .expect("Unable to connect to WS server"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_startup_mock_data_json_matched_request() { + let params = json!({ + "event": "statechange", + "id": "client.Controller.1.events" + }); + let method = "Controller.1.register"; + let mock_data = get_mock_data(json!({ + method: [ + { + "params": params.clone() , + "result": 0 + } + ] + })); + let server = start_server(mock_data).await; + + let response = request_response_with_timeout( + server.clone(), + Message::Text( + json!({"jsonrpc": "2.0", "id":1, "params": params, "method": method.to_owned() }) + .to_string(), + ), + ) + .await + .expect("no response from server within timeout") + .expect("connection to server was closed") + .expect("error in server response"); + + assert_eq!( + response, + Message::Text(json!({"id":1,"jsonrpc":"2.0".to_owned(),"result":0}).to_string()) + ); + + let response = request_response_with_timeout( + server.clone(), + Message::Text( + json!({"jsonrpc": "2.0", "id":1, "params": params, "method": "SomeOthermethod" }) + .to_string(), + ), + ) + .await + .expect("no response from server within timeout") + .expect("connection to server was closed") + .expect("error in server response"); + + let expected = json!({ + "id":1, + "jsonrpc":"2.0".to_owned(), + "error":{ + "code":-32001, + "message":"not found".to_owned() + } + }); + assert!(json_response_validator(&response, &expected)); + + let response = + request_response_with_timeout(server, Message::Text(json!({"jsonrpc": "2.0", "id":1,"method": "Controller.1.status@org.rdk.SomeThunderApi" }).to_string())) + .await + .expect("no response from server within timeout") + .expect("connection to server was closed") + .expect("error in server response"); + + let expected = json!({ + "id":1, + "jsonrpc":"2.0".to_owned(), + "result":[{ + "state":"activated".to_owned() + }] + }); + assert!(json_response_validator(&response, &expected)); + } +} diff --git a/device/mock_device/src/test_utils.rs b/device/mock_device/src/test_utils.rs new file mode 100644 index 000000000..c7b1b6b36 --- /dev/null +++ b/device/mock_device/src/test_utils.rs @@ -0,0 +1,56 @@ +// Copyright 2023 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// + +use std::collections::HashMap; + +use ripple_sdk::{ + async_channel::{unbounded, Receiver}, + extn::{ + client::extn_sender::ExtnSender, + extn_id::{ExtnClassId, ExtnId}, + ffi::ffi_message::CExtnMessage, + }, +}; + +pub fn extn_sender_web_socket_mock_server() -> (ExtnSender, Receiver) { + let (tx, receiver) = unbounded(); + let sender = ExtnSender::new( + tx, + ExtnId::new_channel(ExtnClassId::Device, "mock_device".to_owned()), + vec![], + vec!["web_socket.mock_server".to_owned()], + Some(HashMap::from([( + "mock_data_file".to_owned(), + "examples/device-mock-data/mock-device.json".to_owned(), + )])), + ); + + (sender, receiver) +} + +// pub fn extn_sender_jsonrpsee() -> (ExtnSender, Receiver) { +// let (tx, receiver) = unbounded(); +// let sender = ExtnSender::new( +// tx, +// ExtnId::new_channel(ExtnClassId::Device, "mock_device".to_owned()), +// vec!["web_socket.mock_server".to_owned()], +// vec!["json_rpsee".to_owned()], +// None, +// ); + +// (sender, receiver) +// } diff --git a/device/mock_device/src/utils.rs b/device/mock_device/src/utils.rs new file mode 100644 index 000000000..12e82be0c --- /dev/null +++ b/device/mock_device/src/utils.rs @@ -0,0 +1,193 @@ +// Copyright 2023 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// + +use std::{fs::File, io::BufReader, path::PathBuf, sync::Arc}; + +use ripple_sdk::{ + api::config::Config, + extn::{client::extn_client::ExtnClient, extn_client_message::ExtnResponse}, + log::{debug, error}, + tokio, + utils::error::RippleError, +}; +use serde_json::Value; +use url::{Host, Url}; + +use crate::{ + errors::{BootFailedError, LoadMockDataError, MockDeviceError}, + mock_config::MockConfig, + mock_data::MockData, + mock_web_socket_server::{MockWebSocketServer, WsServerParameters}, +}; + +pub async fn boot_ws_server( + mut client: ExtnClient, +) -> Result, MockDeviceError> { + debug!("Booting WS Server for mock device"); + let gateway = platform_gateway_url(&mut client).await?; + + if gateway.scheme() != "ws" { + return Err(BootFailedError::BadUrlScheme)?; + } + + if !is_valid_host(gateway.host()) { + return Err(BootFailedError::BadHostname)?; + } + + let config = load_config(&client); + + let mut server_config = WsServerParameters::new(); + let mock_data_v2 = load_mock_data_v2(client.clone()).await?; + server_config + .port(gateway.port().unwrap_or(0)) + .path(gateway.path()); + let ws_server = MockWebSocketServer::new(mock_data_v2, server_config, config) + .await + .map_err(BootFailedError::ServerStartFailed)?; + + let ws_server = Arc::new(ws_server); + let server = ws_server.clone(); + + tokio::spawn(async move { + server.start_server().await; + }); + + Ok(ws_server) +} + +async fn platform_gateway_url(client: &mut ExtnClient) -> Result { + debug!("sending request for config.platform_parameters"); + if let Ok(response) = client.request(Config::PlatformParameters).await { + if let Some(ExtnResponse::Value(value)) = response.payload.extract() { + let gateway: Url = value + .as_object() + .and_then(|obj| obj.get("gateway")) + .and_then(|val| val.as_str()) + .and_then(|s| s.parse().ok()) + .ok_or(BootFailedError::GetPlatformGatewayFailed)?; + debug!("{}", gateway); + return Ok(gateway); + } + } + + Err(BootFailedError::GetPlatformGatewayFailed)? +} + +fn is_valid_host(host: Option>) -> bool { + match host { + Some(Host::Ipv4(ipv4)) => ipv4.is_loopback() || ipv4.is_unspecified(), + _ => false, + } +} + +async fn find_mock_device_data_file(mut client: ExtnClient) -> Result { + let file = client + .get_config("mock_data_file") + .unwrap_or("mock-device.json".to_owned()); + let path = PathBuf::from(file); + + debug!( + "mock data path={} absolute={}", + path.display(), + path.is_absolute() + ); + + if path.is_absolute() { + return Ok(path); + } + + let saved_dir = client + .request(Config::SavedDir) + .await + .and_then(|response| -> Result { + if let Some(ExtnResponse::String(value)) = response.payload.extract() { + if let Ok(buf) = value.parse::() { + return Ok(buf); + } + } + + Err(RippleError::ParseError) + }) + .map_err(|e| { + error!("Config::SaveDir request error {:?}", e); + LoadMockDataError::GetSavedDirFailed + })?; + + debug!("received saved_dir {saved_dir:?}"); + if !saved_dir.is_dir() { + return Err(LoadMockDataError::PathDoesNotExist(saved_dir))?; + } + + let path = saved_dir.join(path); + + Ok(path) +} + +pub fn load_config(client: &ExtnClient) -> MockConfig { + let mut config = MockConfig::default(); + + if let Some(c) = client.get_config("activate_all_plugins") { + config.activate_all_plugins = c.parse::().unwrap_or(false); + } + config +} + +pub async fn load_mock_data_v2(client: ExtnClient) -> Result { + let path = find_mock_device_data_file(client).await?; + debug!("path={:?}", path); + if !path.is_file() { + return Err(LoadMockDataError::PathDoesNotExist(path))?; + } + + let file = File::open(path.clone()).map_err(|e| { + error!("Failed to open mock data file {e:?}"); + LoadMockDataError::FileOpenFailed(path) + })?; + let reader = BufReader::new(file); + + if let Ok(v) = serde_json::from_reader(reader) { + return Ok(v); + } + Err(MockDeviceError::LoadMockDataFailed( + LoadMockDataError::MockDataNotValidJson, + )) +} + +pub fn is_value_jsonrpc(value: &Value) -> bool { + value.as_object().map_or(false, |req| { + req.contains_key("jsonrpc") && req.contains_key("id") && req.contains_key("method") + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn test_is_value_jsonrpc_true() { + assert!(is_value_jsonrpc( + &json!({"jsonrpc": "2.0", "id": 1, "method": "someAction", "params": {}}) + )); + } + + #[test] + fn test_is_value_jsonrpc_false() { + assert!(!is_value_jsonrpc(&json!({"key": "value"}))); + } +} diff --git a/device/thunder_ripple_sdk/Cargo.toml b/device/thunder_ripple_sdk/Cargo.toml index 618f63353..36a049b2b 100644 --- a/device/thunder_ripple_sdk/Cargo.toml +++ b/device/thunder_ripple_sdk/Cargo.toml @@ -31,7 +31,8 @@ contract_tests = [ "expectest", "maplit", "test-log", - "home" + "home", + "tree_magic_mini" ] [dependencies] diff --git a/device/thunder_ripple_sdk/src/bootstrap/setup_thunder_pool_step.rs b/device/thunder_ripple_sdk/src/bootstrap/setup_thunder_pool_step.rs index e3786349c..591d70651 100644 --- a/device/thunder_ripple_sdk/src/bootstrap/setup_thunder_pool_step.rs +++ b/device/thunder_ripple_sdk/src/bootstrap/setup_thunder_pool_step.rs @@ -53,6 +53,7 @@ impl ThunderPoolStep { return Err(e); } } + info!("Received Controller pool"); let controller_pool = controller_pool.unwrap(); let expected_plugins = state.plugin_param.clone(); let plugin_manager_tx = @@ -74,7 +75,7 @@ impl ThunderPoolStep { let client = client.unwrap(); info!("Thunder client connected successfully"); - let _ = state.extn_client.event(ExtnStatus::Ready); + let extn_client = state.extn_client.clone(); let thunder_boot_strap_state_with_client = ThunderBootstrapStateWithClient { prev: state, diff --git a/device/thunder_ripple_sdk/src/bootstrap/setup_thunder_processors.rs b/device/thunder_ripple_sdk/src/bootstrap/setup_thunder_processors.rs index dc376b611..80e34c7d8 100644 --- a/device/thunder_ripple_sdk/src/bootstrap/setup_thunder_processors.rs +++ b/device/thunder_ripple_sdk/src/bootstrap/setup_thunder_processors.rs @@ -16,6 +16,7 @@ // use ripple_sdk::api::firebolt::fb_telemetry::OperationalMetricRequest; +use ripple_sdk::api::status_update::ExtnStatus; use ripple_sdk::log::error; use crate::processors::thunder_package_manager::ThunderPackageManagerRequestProcessor; @@ -70,5 +71,6 @@ impl SetupThunderProcessor { } } extn_client.add_request_processor(ThunderRFCProcessor::new(state.clone().state)); + let _ = extn_client.event(ExtnStatus::Ready); } } diff --git a/device/thunder_ripple_sdk/src/client/thunder_client.rs b/device/thunder_ripple_sdk/src/client/thunder_client.rs index e925c4a21..a0198575c 100644 --- a/device/thunder_ripple_sdk/src/client/thunder_client.rs +++ b/device/thunder_ripple_sdk/src/client/thunder_client.rs @@ -304,9 +304,13 @@ impl ThunderClient { let sub_id_c = sub_id.clone(); let handle = ripple_sdk::tokio::spawn(async move { while let Some(ev_res) = subscription.next().await { - if let Ok(ev) = ev_res { - let msg = DeviceResponseMessage::sub(ev, sub_id_c.clone()); - mpsc_send_and_log(&thunder_message.handler, msg, "ThunderSubscribeEvent").await; + match ev_res { + Ok(ev) => { + let msg = DeviceResponseMessage::sub(ev, sub_id_c.clone()); + mpsc_send_and_log(&thunder_message.handler, msg, "ThunderSubscribeEvent") + .await; + } + Err(e) => error!("Thunder event error {e:?}"), } } if let Some(ptx) = pool_tx { @@ -531,6 +535,7 @@ impl ThunderClientBuilder { let client = Self::create_client(url, thunder_connection_state.clone()).await; // add error handling here if client.is_err() { + error!("Unable to connect to thunder: {client:?}"); return Err(RippleError::BootstrapError); } diff --git a/device/thunder_ripple_sdk/src/events/thunder_event_processor.rs b/device/thunder_ripple_sdk/src/events/thunder_event_processor.rs index 47058890c..c58ec68d5 100644 --- a/device/thunder_ripple_sdk/src/events/thunder_event_processor.rs +++ b/device/thunder_ripple_sdk/src/events/thunder_event_processor.rs @@ -59,6 +59,7 @@ pub struct TimeZoneChangedThunderEvent { } #[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct ResolutionChangedEvent { pub width: i32, pub height: i32, diff --git a/device/thunder_ripple_sdk/src/processors/thunder_device_info.rs b/device/thunder_ripple_sdk/src/processors/thunder_device_info.rs index af06dc58f..d984a1027 100644 --- a/device/thunder_ripple_sdk/src/processors/thunder_device_info.rs +++ b/device/thunder_ripple_sdk/src/processors/thunder_device_info.rs @@ -995,28 +995,34 @@ impl ThunderDeviceInfoRequestProcessor { }) .await; info!("{}", resp.message); - let tsv: SystemVersion = serde_json::from_value(resp.message).unwrap(); - let tsv_split = tsv.receiver_version.split('.'); - let tsv_vec: Vec<&str> = tsv_split.collect(); - - if tsv_vec.len() >= 3 { - let major: String = tsv_vec[0].chars().filter(|c| c.is_ascii_digit()).collect(); - let minor: String = tsv_vec[1].chars().filter(|c| c.is_ascii_digit()).collect(); - let patch: String = tsv_vec[2].chars().filter(|c| c.is_ascii_digit()).collect(); - - version = FireboltSemanticVersion { - major: major.parse::().unwrap(), - minor: minor.parse::().unwrap(), - patch: patch.parse::().unwrap(), - readable: tsv.stb_version, - }; - state.update_version(version.clone()); + if let Ok(tsv) = serde_json::from_value::(resp.message) { + let tsv_split = tsv.receiver_version.split('.'); + let tsv_vec: Vec<&str> = tsv_split.collect(); + + if tsv_vec.len() >= 3 { + let major: String = + tsv_vec[0].chars().filter(|c| c.is_ascii_digit()).collect(); + let minor: String = + tsv_vec[1].chars().filter(|c| c.is_ascii_digit()).collect(); + let patch: String = + tsv_vec[2].chars().filter(|c| c.is_ascii_digit()).collect(); + + version = FireboltSemanticVersion { + major: major.parse::().unwrap(), + minor: minor.parse::().unwrap(), + patch: patch.parse::().unwrap(), + readable: tsv.stb_version, + }; + state.update_version(version.clone()); + } else { + version = FireboltSemanticVersion { + readable: tsv.stb_version, + ..FireboltSemanticVersion::default() + }; + state.update_version(version.clone()) + } } else { - version = FireboltSemanticVersion { - readable: tsv.stb_version, - ..FireboltSemanticVersion::default() - }; - state.update_version(version.clone()) + version = FireboltSemanticVersion::default() } } } diff --git a/docs/mock-device.md b/docs/mock-device.md new file mode 100644 index 000000000..5ffce09d7 --- /dev/null +++ b/docs/mock-device.md @@ -0,0 +1,235 @@ +# Mock Device Extension + +The mock device extension provides functionality that allows Ripple to be run without an underlying device. This is useful for testing and certain development workloads where the a real device is not actually needed and the interactions with the device are known. + +The operation of the extension is quite simple. Once the extension loads it looks up the PlatformParameters from the device manifest. These parameters contain the platform gateway url. The extension takes this URL and starts up a websocket server at the gateway which leads to extensions that fulfill the device contracts connecting to this websocket server instead of the service that would be running on a real device. This websocket server contains a registry of requests and responses that determine the behaviour of interactions. When the server receives a request, it is looked up in the registry. If a match is found the corresponding request is sent back to the client. The extension also offers an interface through the Ripple WS Gateway to control the data contained within the registry. + +## Extension Manifest + +There is an example manifest in the `examples` folder that shows how to get the mock_device extension setup. The file is called `mock-thunder-device.json`. The important part of this file is the libmock_device entry in the `extns` array. + +```json +{ + "path": "libmock_device", + "symbols": [ + { + "id": "ripple:channel:device:mock_device", + "config": { + "mock_data_file": "mock-device.json", + "activate_all_plugins": "true" + }, + "uses": [ + "config" + ], + "fulfills": [ + ] + }, + { + "id": "ripple:extn:jsonrpsee:mock_device", + "uses": [ + "ripple:channel:device:mock_device" + ], + "fulfills": [ + "json_rpsee" + ] + } + ] + } +``` + +The extension has two symbols in it. One for the websocket server channel and the other to add the RPC methods for controlling the mock server to the Ripple gateway. + +Once your extn manifest has been updated to include this entry you will be able to run ripple on a machine that does not have the platform service running. + +## Usage + +### Initial mocks + +Due to timing requirements of platform integrations it is often the case that you will need mock data in the server as soon as it starts, rather than adding it at runtime. This is prevents platform integration extensions from crashing when they make requests to the platform durin initilization. The mock device extension supports this use case by allowing the user to stored their mock data in a JSON file which will be loaded and passed to the websocket server before it starts accepting requests. + +The file contains a request and response map with additional support for events with delay. +```json +{ + "org.rdk.System.1.getSystemVersions": [ + { + "result": { + "receiverVersion": "6.9.0.0", + "stbTimestamp": "Tue 07 Nov 2023 00:03:20 AP UTC", + "stbVersion": "SCXI11BEI_VBN_23Q4_sprint_20231107000320sdy_FG_EDGE_R2PB_NG", + "success": true + } + } + ], + "org.rdk.System.register": [ + { + "params": { + "event": "onTimeZoneDSTChanged", + "id": "client.org.rdk.System.events" + }, + "result": 0, + "events": [ + { + "delay": 0, + "data": { + "oldTimeZone": "America/New_York", + "newTimeZone": "Europe/London", + "oldAccuracy": "INITIAL", + "newAccuracy": "FINAL" + } + } + ] + }, + { + "params": { + "event": "onSystemPowerStateChanged", + "id": "client.org.rdk.System.events" + }, + "result": 0, + "events": [ + { + "delay": 0, + "data": { + "powerState": "ON", + "currentPowerState": "ON" + } + } + ] + } + ] + +} +``` + +By default, this file is looked for in the ripple persistent folder under the name `mock-device.json` e.g. `~/.ripple/mock-device.json`. The location of this file can be controlled with the config setting in the channel sysmobl of the extensions manifest entry e.g. + +```json +{ + "id": "ripple:channel:device:mock_device", + "config": { + "mock_data_file": "/mock-device.json" + }, + ... +} +``` + +If the path is absolute it will be loaded from there. Otherwise the config entry will be appended to the `saved_dir` config setting from the device manifest. + +An example for the Thunder platform can be found at `examples/mock-data/thunder-device.json`. + + +### Runtime mocks + +Once Ripple is running the the mock device extension is loaded you will be able to add new mock data into the server using the following APIs. You must establish a websocket connection to ripple on the port being used for app connections (by default `3474`). You can use a dummy appId for this connection. An example gateway URL would be: `ws://127.0.0.1:3474?appId=test&session=test`. Once connected you can make JSON-RPC calls to the mock_device extension. + +### AddRequestResponse + +Used to add a request and corresponding responses to the mock server registry. If the request being added is already present, the responses will be overitten with the new data. + +Payload: +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "mockdevice.addRequests", + "params": { + "org.rdk.DisplaySettings.1.getCurrentResolution": [ + { + "params": { + "videoDisplay": "HDMI0" + }, + "result": { + "resolution": "2160p", + "success": true + } + } + ] + } +} +``` + +If you submit the example payload above, you will then be able to submit a device.version request on the same connection and you will get the Firebolt response populated by data from the mock_device. + +Request: +```json +{ + "jsonrpc": "2.0", + "id": 5, + "method": "device.screenResolution" +} +``` + +Response: +```json +{ + "jsonrpc": "2.0", + "result": [ + 3840, + 2160 + ], + "id": 1 +} +``` + +### RemoveRequest + +Removes a request from the registry. + +Payload: +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "mockdevice.removeRequests", + "params": { + "org.rdk.DisplaySettings.1.getCurrentResolution": [ + { + "params": { + "videoDisplay": "HDMI0" + }, + "result": { + "resolution": "2160p", + "success": true + } + } + ] + } +} +``` + +### Emitting Events +Mock device extension can also provide ability to emit events for an existing register Thunder listener. +Below is an example of emitting screen resolution event. + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "mockdevice.emitEvent", + "params": { + "event": { + "body": { + "jsonrpc": "2.0", + "method": "client.org.rdk.DisplaySettings.events.resolutionChanged", + "params": { + "width": 3840, + "height": 2160, + "videoDisplayType": "HDMI0", + "resolution": "2160p" + } + }, + "delay": 0 + } + } +} +``` + +## Payload types + +Payload types MUST match the original schema definition from the mock data file. + +# TODO + +What's left? + +- Integration tests for the mock device extension +- Unit tests covering extension client interactions \ No newline at end of file diff --git a/examples/manifest/extn-manifest-mock-device-example.json b/examples/manifest/extn-manifest-mock-device-example.json new file mode 100644 index 000000000..acc5fda94 --- /dev/null +++ b/examples/manifest/extn-manifest-mock-device-example.json @@ -0,0 +1,98 @@ +{ + "default_path": "/usr/lib/rust/", + "default_extension": "so", + "timeout": 2000, + "extns": [ + { + "path": "libthunder", + "symbols": [ + { + "id": "ripple:channel:device:thunder", + "uses": [ + "config" + ], + "fulfills": [ + "device_info", + "window_manager", + "browser", + "wifi", + "device_events", + "device_persistence", + "remote_accessory" + ] + } + ] + }, + { + "path": "libdistributor_general", + "symbols": [ + { + "id": "ripple:channel:distributor:general", + "uses": [ + "config" + ], + "fulfills": [ + "permissions", + "account_session", + "secure_storage", + "advertising", + "privacy_settings", + "metrics", + "session_token", + "discovery", + "media_events" + ] + } + ] + }, + { + "path": "libmock_device", + "symbols": [ + { + "id": "ripple:channel:device:mock_device", + "config": { + "mock_data_file": "mock-device.json" + }, + "uses": [ + "config" + ], + "fulfills": [ + ] + }, + { + "id": "ripple:extn:jsonrpsee:mock_device", + "uses": [ + ], + "fulfills": [ + "json_rpsee" + ] + } + ] + } + ], + "required_contracts": [ + "rpc", + "lifecycle_management", + "device_info", + "window_manager", + "browser", + "permissions", + "account_session", + "wifi", + "device_events", + "device_persistence", + "remote_accessory", + "secure_storage", + "advertising", + "privacy_settings", + "session_token", + "metrics", + "discovery", + "media_events" + ], + "rpc_aliases": { + "device.model": [ + "custom.model" + ] + } +} \ No newline at end of file diff --git a/examples/manifest/mock/mock-app-library.json b/examples/manifest/mock/mock-app-library.json new file mode 100644 index 000000000..fbfc6fcf3 --- /dev/null +++ b/examples/manifest/mock/mock-app-library.json @@ -0,0 +1,3 @@ +{ + "default_library": [] +} \ No newline at end of file diff --git a/examples/manifest/mock/mock-device-manifest.json b/examples/manifest/mock/mock-device-manifest.json new file mode 100644 index 000000000..459a52945 --- /dev/null +++ b/examples/manifest/mock/mock-device-manifest.json @@ -0,0 +1,140 @@ +{ + "configuration": { + "ws_configuration": { + "enabled": true, + "gateway": "127.0.0.1:3473" + }, + "internal_ws_configuration": { + "enabled": true, + "gateway": "127.0.0.1:3474" + }, + "platform": "Thunder", + "platform_parameters": { + "gateway": "ws://127.0.0.1:9998/jsonrpc" + }, + "distribution_platform": "Generic", + "distribution_tenant": "reference", + "form_factor": "ipstb", + "default_values": { + "country_code": "US", + "language": "en", + "locale": "en-US", + "name": "Living Room", + "captions": { + "enabled": false, + "font_family": "sans-serif", + "font_size": 1, + "font_color": "#ffffff", + "font_edge": "none", + "font_edge_color": "#7F7F7F", + "font_opacity": 100, + "background_color": "#000000", + "background_opacity": 12, + "text_align": "center", + "text_align_vertical": "middle" + }, + "voice": { + "enabled": true, + "speed": 5 + } + }, + "model_friendly_names": { + "RSPPI": "Raspberry PI" + }, + "distributor_experience_id": "0000", + "exclusory": { + "resolve_only": ["device.model", "localization.postalCode"], + "app_authorization_rules": { + "app_ignore_rules": { + "foo-insecure": [ + "*" + ], + "refui": [ + "*" + ] + } + }, + "method_ignore_rules": [ + "some.nonexistent.method" + ] + } + }, + "capabilities": { + "supported": [ + "xrn:firebolt:capability:lifecycle:state", + "xrn:firebolt:capability:lifecycle:initialize", + "xrn:firebolt:capability:lifecycle:ready", + "xrn:firebolt:capability:discovery:watched", + "xrn:firebolt:capability:accessibility:closedcaptions", + "xrn:firebolt:capability:accessibility:voiceguidance", + "xrn:firebolt:capability:account:id", + "xrn:firebolt:capability:account:uid", + "xrn:firebolt:capability:token:account", + "xrn:firebolt:capability:approve:content", + "xrn:firebolt:capability:approve:purchase", + "xrn:firebolt:capability:device:distributor", + "xrn:firebolt:capability:device:id", + "xrn:firebolt:capability:device:info", + "xrn:firebolt:capability:device:make", + "xrn:firebolt:capability:device:model", + "xrn:firebolt:capability:device:name", + "xrn:firebolt:capability:device:sku", + "xrn:firebolt:capability:device:uid", + "xrn:firebolt:capability:protocol:wifi", + "xrn:firebolt:capability:discovery:entity-info", + "xrn:firebolt:capability:discovery:navigate-to", + "xrn:firebolt:capability:discovery:policy", + "xrn:firebolt:capability:discovery:purchased-content", + "xrn:firebolt:capability:lifecycle:launch", + "xrn:firebolt:capability:localization:country-code", + "xrn:firebolt:capability:localization:language", + "xrn:firebolt:capability:localization:locale", + "xrn:firebolt:capability:localization:locality", + "xrn:firebolt:capability:localization:postal-code", + "xrn:firebolt:capability:localization:time-zone", + "xrn:firebolt:capability:metrics:general", + "xrn:firebolt:capability:metrics:media", + "xrn:firebolt:capability:network:status", + "xrn:firebolt:capability:power:state", + "xrn:firebolt:capability:privacy:advertising", + "xrn:firebolt:capability:privacy:content", + "xrn:firebolt:capability:profile:flags", + "xrn:firebolt:capability:usergrant:pinchallenge", + "xrn:firebolt:capability:usergrant:acknowledgechallenge", + "xrn:firebolt:capability:input:keyboard", + "xrn:firebolt:capability:accessory:pair", + "xrn:firebolt:capability:accessory:list", + "xrn:firebolt:capability:remote:ble", + "xrn:firebolt:capability:advertising:configuration", + "xrn:firebolt:capability:advertising:identifier", + "xrn:firebolt:capability:privacy:advertising", + "xrn:firebolt:capability:metrics:general", + "xrn:firebolt:capability:metrics:media", + "xrn:firebolt:capability:protocol:dial", + "xrn:firebolt:capability:token:session", + "xrn:firebolt:capability:token:platform", + "xrn:firebolt:capability:token:device", + "xrn:firebolt:capability:token:root", + "xrn:firebolt:capability:accessibility:audiodescriptions", + "xrn:firebolt:capability:inputs:hdmi", + "xrn:firebolt:capability:mock-device:request-response" + ] + }, + "lifecycle": { + "appReadyTimeoutMs": 30000, + "appFinishedTimeoutMs": 2000, + "maxLoadedApps": 5, + "minAvailableMemoryKb": 1024, + "prioritized": [] + }, + "applications": { + "distribution": { + "library": "/etc/firebolt-app-library.json", + "catalog": "" + }, + "defaults": { + "xrn:firebolt:application-type:main": "", + "xrn:firebolt:application-type:settings": "" + } + } +} diff --git a/examples/manifest/mock/mock-extn-manifest.json b/examples/manifest/mock/mock-extn-manifest.json new file mode 100644 index 000000000..f20140e8c --- /dev/null +++ b/examples/manifest/mock/mock-extn-manifest.json @@ -0,0 +1,136 @@ +{ + "default_path": "/usr/lib/rust/", + "default_extension": "so", + "timeout": 2000, + "extns": [ + { + "path": "libthunder", + "symbols": [ + { + "id": "ripple:channel:device:thunder", + "uses": [ + "config" + ], + "fulfills": [ + "device_info", + "window_manager", + "browser", + "wifi", + "device_events", + "device_persistence", + "remote_accessory", + "local.storage", + "input.device_events", + "hdr.device_events", + "screen_resolution.device_events", + "video_resolution.device_events", + "voice_guidance.device_events", + "network.device_events", + "internet.device_events", + "audio.device_events", + "system_power_state.device_events", + "time_zone.device_events", + "remote_feature_control", + "apps" + ] + } + ] + }, + { + "path": "libdistributor_general", + "symbols": [ + { + "id": "ripple:channel:distributor:general", + "uses": [ + "config" + ], + "fulfills": [ + "permissions", + "account.session", + "secure.storage", + "advertising", + "privacy_cloud.storage", + "metrics", + "session.token", + "discovery", + "media_events", + "behavior_metrics", + "root.session", + "device.session" + ] + } + ] + }, + { + "path": "libmock_device", + "symbols": [ + { + "id": "ripple:channel:device:mock_device", + "config": { + "mock_data_file": "mock-device.json", + "activate_all_plugins": "true" + }, + "uses": [ + "config" + ], + "fulfills": [ + ] + }, + { + "id": "ripple:extn:jsonrpsee:mock_device", + "uses": [ + "ripple:channel:device:mock_device" + ], + "fulfills": [ + "json_rpsee" + ] + } + ] + } + ], + "required_contracts": [ + "rpc", + "lifecycle_management", + "device_info", + "window_manager", + "browser", + "permissions", + "account_session", + "wifi", + "device_events", + "device_persistence", + "remote_accessory", + "secure_storage", + "advertising", + "privacy_settings", + "session_token", + "metrics", + "discovery", + "media_events", + "account.session" + ], + "rpc_aliases": { + "device.model": [ + "custom.model" + ] + }, + "passthrough_rpcs": { + "endpoints": [ + { + "url": "ws://127.0.0.1:9998/jsonrpc", + "protocol": "thunder", + "rpcs": [ + { + "matcher":"HDMIInput.*", + "transformer": { + "HDMIInput.onAutoLowLatencyModeSignalChanged": { + "module":"org.rdk.HdmiInput", + "method": "gameFeatureStatusUpdate" + } + } + } + ] + } + ] + } +} \ No newline at end of file diff --git a/examples/manifest/mock/mock-thunder-device.json b/examples/manifest/mock/mock-thunder-device.json new file mode 100644 index 000000000..a7cd4b7ea --- /dev/null +++ b/examples/manifest/mock/mock-thunder-device.json @@ -0,0 +1,77 @@ + + { + "Controller.1.register": [ + { + "params": { + "event": "statechange", + "id": "client.Controller.1.events" + }, + "result": 0 + } + ], + "org.rdk.System.1.getSystemVersions": [ + { + "result": { + "receiverVersion": "6.9.0.0", + "stbTimestamp": "Tue 07 Nov 2023 00:03:20 AP UTC", + "stbVersion": "SCXI11BEI_VBN_23Q4_sprint_20231107000320sdy_FG_EDGE_R2PB_NG", + "success": true + } + } + ], + "org.rdk.System.register": [ + { + "params": { + "event":"onTimeZoneDSTChanged", + "id":"client.org.rdk.System.events" + }, + "result": 0, + "events": [ + { + "delay": 0, + "data": { + "oldTimeZone": "America/New_York", + "newTimeZone": "Europe/London", + "oldAccuracy": "INITIAL", + "newAccuracy": "FINAL" + } + + } + ] + }, + { + "params": { + "event":"onSystemPowerStateChanged", + "id":"client.org.rdk.System.events" + }, + "result": 0, + "events": [ + { + "delay": 0, + "data": { + "powerState": "ON", + "currentPowerState": "ON" + } + } + ] + } + ], + "org.rdk.Network.register": [ + { + "params": { + "event":"onInternetStatusChange", + "id":"client.org.rdk.Network.events" + }, + "result": 0, + "events": [ + { + "delay": 0, + "data": { + "state": 0, + "status": "FULLY_CONNECTED" + } + } + ] + } + ] + } \ No newline at end of file diff --git a/ripple b/ripple index 2b980d197..7fb5fb9dd 100755 --- a/ripple +++ b/ripple @@ -83,12 +83,12 @@ case ${1} in cp ./examples/manifest/extn-manifest-example.json ~/.ripple/firebolt-extn-manifest.json ## Update firebolt-extn-manifest.json - sed -i "" "s@\"default_path\": \"/usr/lib/rust/\"@\"default_path\": \"$workspace_dir/target/debug\"@" ~/.ripple/firebolt-extn-manifest.json + sed -i "" "s@\"default_path\": \"/usr/lib/rust/\"@\"default_path\": \"$workspace_dir/target/debug/\"@" ~/.ripple/firebolt-extn-manifest.json default_extension=$(get_default_extension) sed -i "" "s@\"default_extension\": \"so\"@\"default_extension\": \"$default_extension\"@" ~/.ripple/firebolt-extn-manifest.json ## Update firebolt-device-manifest.json - sed -i "" "s@\"library\": \"/etc/firebolt-app-library.json\"@\"library\": \"~/.ripple/firebolt-app-library.json\"@" ~/.ripple/firebolt-device-manifest.json + sed -i "" "s@\"library\": \"/etc/firebolt-app-library.json\"@\"library\": \"$HOME/.ripple/firebolt-app-library.json\"@" ~/.ripple/firebolt-device-manifest.json echo "All Done!" ;; @@ -96,6 +96,27 @@ case ${1} in cargo build --features local_dev THUNDER_HOST=${2} cargo run --features local_dev core/main ;; + "run-mock") + workspace_dir=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + mkdir -p target/manifests + rm -rf target/manifests/* + cp examples/manifest/mock/mock-device-manifest.json target/manifests/firebolt-device-manifest.json + cp examples/manifest/mock/mock-app-library.json target/manifests/firebolt-app-library.json + cp examples/manifest/mock/mock-extn-manifest.json target/manifests/firebolt-extn-manifest.json + cp examples/manifest/mock/mock-thunder-device.json target/manifests/mock-thunder-device.json + + sed -i "" "s@\"default_path\": \"/usr/lib/rust/\"@\"default_path\": \"$workspace_dir/target/debug/\"@" target/manifests/firebolt-extn-manifest.json + default_extension=$(get_default_extension) + sed -i "" "s@\"default_extension\": \"so\"@\"default_extension\": \"$default_extension\"@" target/manifests/firebolt-extn-manifest.json + + ## Update firebolt-device-manifest.json + sed -i "" "s@\"library\": \"/etc/firebolt-app-library.json\"@\"library\": \"$workspace_dir/target/manifests/firebolt-app-library.json\"@" target/manifests/firebolt-device-manifest.json + sed -i "" "s@\"mock_data_file\": \"mock-device.json\"@\"mock_data_file\": \"$workspace_dir/target/manifests/mock-thunder-device.json\"@" target/manifests/firebolt-extn-manifest.json + export EXTN_MANIFEST=${workspace_dir}/target/manifests/firebolt-extn-manifest.json + export DEVICE_MANIFEST=${workspace_dir}/target/manifests/firebolt-device-manifest.json + cargo build --features local_dev + cargo run --features local_dev core/main + ;; "-h") print_help ;;