diff --git a/api/proto/api/device_profile.proto b/api/proto/api/device_profile.proto index fd555240..34f87a0e 100644 --- a/api/proto/api/device_profile.proto +++ b/api/proto/api/device_profile.proto @@ -24,6 +24,9 @@ enum CodecRuntime { // JavaScript. JS = 2; + + // JavaScript plugin. + JS_PLUGIN = 3; } enum MeasurementKind { @@ -178,6 +181,14 @@ service DeviceProfileService { get : "/api/device-profiles/adr-algorithms" }; } + + // List available codec plugins. + rpc ListCodecPlugins(google.protobuf.Empty) + returns (ListDeviceProfileCodecPluginsResponse) { + option (google.api.http) = { + get : "/api/device-profiles/codec-plugins" + }; + } } message DeviceProfile { @@ -457,6 +468,9 @@ message DeviceProfile { // Application Layer parameters. AppLayerParams app_layer_params = 54; + + // Payload codec plugin ID. + string codec_plugin_id = 55; } message Measurement { @@ -593,3 +607,19 @@ message AdrAlgorithmListItem { // Algorithm name. string name = 2; } + +message ListDeviceProfileCodecPluginsResponse { + // Total number of plugins. + uint32 total_count = 1; + + // Result-set. + repeated CodecPluginListItem result = 2; +} + +message CodecPluginListItem { + // Pluigin ID. + string id = 1; + + // Plugin name. + string name = 2; +} \ No newline at end of file diff --git a/api/proto/api/device_profile_template.proto b/api/proto/api/device_profile_template.proto index e12aab7a..acad5922 100644 --- a/api/proto/api/device_profile_template.proto +++ b/api/proto/api/device_profile_template.proto @@ -157,6 +157,9 @@ message DeviceProfileTemplate { // keys of the decoded payload. In cases where the decoded payload contains // random keys in the data, you want to set this to false. bool auto_detect_measurements = 29; + + // Payload codec plugin ID. + string codec_plugin_id = 30; } message DeviceProfileTemplateListItem { diff --git a/api/rust/proto/chirpstack/api/device_profile.proto b/api/rust/proto/chirpstack/api/device_profile.proto index fd555240..20527829 100644 --- a/api/rust/proto/chirpstack/api/device_profile.proto +++ b/api/rust/proto/chirpstack/api/device_profile.proto @@ -24,6 +24,9 @@ enum CodecRuntime { // JavaScript. JS = 2; + + // JavaScript plugin. + JS_PLUGIN = 3; } enum MeasurementKind { @@ -178,6 +181,14 @@ service DeviceProfileService { get : "/api/device-profiles/adr-algorithms" }; } + + // List available codec plugins. + rpc ListCodecPlugins(google.protobuf.Empty) + returns (ListDeviceProfileCodecPluginsResponse) { + option (google.api.http) = { + get : "/api/device-profiles/codec-plugins" + }; + } } message DeviceProfile { @@ -457,6 +468,9 @@ message DeviceProfile { // Application Layer parameters. AppLayerParams app_layer_params = 54; + + // Payload codec plugin ID. + string codec_plugin_id = 55; } message Measurement { @@ -593,3 +607,19 @@ message AdrAlgorithmListItem { // Algorithm name. string name = 2; } + +message ListDeviceProfileCodecPluginsResponse { + // Total number of plugins. + uint32 total_count = 1; + + // Result-set. + repeated CodecPluginListItem result = 2; +} + +message CodecPluginListItem { + // Pluigin ID. + string id = 1; + + // Plugin name. + string name = 2; +} diff --git a/api/rust/proto/chirpstack/api/device_profile_template.proto b/api/rust/proto/chirpstack/api/device_profile_template.proto index e12aab7a..acad5922 100644 --- a/api/rust/proto/chirpstack/api/device_profile_template.proto +++ b/api/rust/proto/chirpstack/api/device_profile_template.proto @@ -157,6 +157,9 @@ message DeviceProfileTemplate { // keys of the decoded payload. In cases where the decoded payload contains // random keys in the data, you want to set this to false. bool auto_detect_measurements = 29; + + // Payload codec plugin ID. + string codec_plugin_id = 30; } message DeviceProfileTemplateListItem { diff --git a/chirpstack/migrations_postgres/2025-03-17-100300_add_codec_plugin_id/down.sql b/chirpstack/migrations_postgres/2025-03-17-100300_add_codec_plugin_id/down.sql new file mode 100644 index 00000000..8568b7db --- /dev/null +++ b/chirpstack/migrations_postgres/2025-03-17-100300_add_codec_plugin_id/down.sql @@ -0,0 +1,5 @@ +alter table device_profile + drop column codec_plugin_id; + +alter table device_profile_template + drop column codec_plugin_id; diff --git a/chirpstack/migrations_postgres/2025-03-17-100300_add_codec_plugin_id/up.sql b/chirpstack/migrations_postgres/2025-03-17-100300_add_codec_plugin_id/up.sql new file mode 100644 index 00000000..5cbb0633 --- /dev/null +++ b/chirpstack/migrations_postgres/2025-03-17-100300_add_codec_plugin_id/up.sql @@ -0,0 +1,5 @@ +alter table device_profile + add column codec_plugin_id varchar(100) not null default ''; + +alter table device_profile_template + add column codec_plugin_id varchar(100) not null default ''; diff --git a/chirpstack/migrations_sqlite/2025-03-17-100300_add_codec_plugin_id/down.sql b/chirpstack/migrations_sqlite/2025-03-17-100300_add_codec_plugin_id/down.sql new file mode 100644 index 00000000..30fda8c9 --- /dev/null +++ b/chirpstack/migrations_sqlite/2025-03-17-100300_add_codec_plugin_id/down.sql @@ -0,0 +1,5 @@ +alter table device_profile + drop column codec_plugin_id; + +alter table device_profile_template + drop column codec_plugin_id; \ No newline at end of file diff --git a/chirpstack/migrations_sqlite/2025-03-17-100300_add_codec_plugin_id/up.sql b/chirpstack/migrations_sqlite/2025-03-17-100300_add_codec_plugin_id/up.sql new file mode 100644 index 00000000..b07a209b --- /dev/null +++ b/chirpstack/migrations_sqlite/2025-03-17-100300_add_codec_plugin_id/up.sql @@ -0,0 +1,5 @@ +alter table device_profile + add column codec_plugin_id varchar(100) not null default ''; + +alter table device_profile_template + add column codec_plugin_id varchar(100) not null default ''; \ No newline at end of file diff --git a/chirpstack/src/api/device.rs b/chirpstack/src/api/device.rs index c1acf62a..545246cf 100644 --- a/chirpstack/src/api/device.rs +++ b/chirpstack/src/api/device.rs @@ -1087,6 +1087,7 @@ impl DeviceService for Device { req_qi.f_port as u8, &dev.variables, &dp.payload_codec_script, + &dp.codec_plugin_id, obj, ) .await diff --git a/chirpstack/src/api/device_profile.rs b/chirpstack/src/api/device_profile.rs index ede887e2..b822e9f9 100644 --- a/chirpstack/src/api/device_profile.rs +++ b/chirpstack/src/api/device_profile.rs @@ -11,6 +11,7 @@ use super::error::ToStatus; use super::helpers; use super::helpers::{FromProto, ToProto}; use crate::adr; +use crate::codec; use crate::storage::{device_profile, fields}; pub struct DeviceProfile { @@ -52,6 +53,7 @@ impl DeviceProfileService for DeviceProfile { mac_version: req_dp.mac_version().from_proto(), reg_params_revision: req_dp.reg_params_revision().from_proto(), adr_algorithm_id: req_dp.adr_algorithm_id.clone(), + codec_plugin_id: req_dp.codec_plugin_id.clone(), payload_codec_runtime: req_dp.payload_codec_runtime().from_proto(), payload_codec_script: req_dp.payload_codec_script.clone(), flush_queue_on_activate: req_dp.flush_queue_on_activate, @@ -196,6 +198,7 @@ impl DeviceProfileService for DeviceProfile { mac_version: dp.mac_version.to_proto().into(), reg_params_revision: dp.reg_params_revision.to_proto().into(), adr_algorithm_id: dp.adr_algorithm_id, + codec_plugin_id: dp.codec_plugin_id, payload_codec_runtime: dp.payload_codec_runtime.to_proto().into(), payload_codec_script: dp.payload_codec_script, flush_queue_on_activate: dp.flush_queue_on_activate, @@ -308,6 +311,7 @@ impl DeviceProfileService for DeviceProfile { mac_version: req_dp.mac_version().from_proto(), reg_params_revision: req_dp.reg_params_revision().from_proto(), adr_algorithm_id: req_dp.adr_algorithm_id.clone(), + codec_plugin_id: req_dp.codec_plugin_id.clone(), payload_codec_runtime: req_dp.payload_codec_runtime().from_proto(), payload_codec_script: req_dp.payload_codec_script.clone(), flush_queue_on_activate: req_dp.flush_queue_on_activate, @@ -525,6 +529,33 @@ impl DeviceProfileService for DeviceProfile { result, })) } + + async fn list_codec_plugins( + &self, + request: Request<()>, + ) -> Result, Status> { + self.validator + .validate( + request.extensions(), + validator::ValidateActiveUserOrKey::new(), + ) + .await?; + + let items = codec::js_plugin::get_plugins().await; + let mut result: Vec = items + .iter() + .map(|(k, v)| api::CodecPluginListItem { + id: k.clone(), + name: v.clone(), + }) + .collect(); + result.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok(Response::new(api::ListDeviceProfileCodecPluginsResponse { + total_count: items.len() as u32, + result, + })) + } } #[cfg(test)] @@ -574,6 +605,7 @@ pub mod test { mac_version: common::MacVersion::Lorawan103.into(), reg_params_revision: common::RegParamsRevision::A.into(), adr_algorithm_id: "default".into(), + codec_plugin_id: "passthrough".into(), ..Default::default() }), }, @@ -606,6 +638,7 @@ pub mod test { ts005_version: api::Ts005Version::Ts005NotImplemented.into(), ts005_f_port: 200, }), + codec_plugin_id: "passthrough".into(), ..Default::default() }), get_resp.get_ref().device_profile @@ -623,6 +656,7 @@ pub mod test { mac_version: common::MacVersion::Lorawan103.into(), reg_params_revision: common::RegParamsRevision::A.into(), adr_algorithm_id: "default".into(), + codec_plugin_id: "passthrough".into(), ..Default::default() }), }, @@ -647,6 +681,7 @@ pub mod test { reg_params_revision: common::RegParamsRevision::A.into(), adr_algorithm_id: "default".into(), app_layer_params: Some(api::AppLayerParams::default()), + codec_plugin_id: "passthrough".into(), ..Default::default() }), get_resp.get_ref().device_profile @@ -697,6 +732,17 @@ pub mod test { assert_eq!("default", list_adr_algs_resp.result[0].id); assert_eq!("lr_fhss", list_adr_algs_resp.result[1].id); assert_eq!("lora_lr_fhss", list_adr_algs_resp.result[2].id); + + // list codec plugins + let list_codec_plugins_req = get_request(&u.id, ()); + let list_codec_plugins_resp = service + .list_codec_plugins(list_codec_plugins_req) + .await + .unwrap(); + let list_codec_plugins_resp = list_codec_plugins_resp.get_ref(); + assert_eq!(1, list_codec_plugins_resp.total_count); + assert_eq!(1, list_codec_plugins_resp.result.len()); + assert_eq!("passthrough", list_codec_plugins_resp.result[0].id); } fn get_request(user_id: &Uuid, req: T) -> Request { diff --git a/chirpstack/src/api/device_profile_template.rs b/chirpstack/src/api/device_profile_template.rs index 33e4f122..1c8f89d8 100644 --- a/chirpstack/src/api/device_profile_template.rs +++ b/chirpstack/src/api/device_profile_template.rs @@ -51,6 +51,7 @@ impl DeviceProfileTemplateService for DeviceProfileTemplate { mac_version: req_dp.mac_version().from_proto(), reg_params_revision: req_dp.reg_params_revision().from_proto(), adr_algorithm_id: req_dp.adr_algorithm_id.clone(), + codec_plugin_id: req_dp.codec_plugin_id.clone(), payload_codec_runtime: req_dp.payload_codec_runtime().from_proto(), payload_codec_script: req_dp.payload_codec_script.clone(), flush_queue_on_activate: req_dp.flush_queue_on_activate, @@ -123,6 +124,7 @@ impl DeviceProfileTemplateService for DeviceProfileTemplate { mac_version: dp.mac_version.to_proto().into(), reg_params_revision: dp.reg_params_revision.to_proto().into(), adr_algorithm_id: dp.adr_algorithm_id, + codec_plugin_id: dp.codec_plugin_id, payload_codec_runtime: dp.payload_codec_runtime.to_proto().into(), payload_codec_script: dp.payload_codec_script, flush_queue_on_activate: dp.flush_queue_on_activate, @@ -192,6 +194,7 @@ impl DeviceProfileTemplateService for DeviceProfileTemplate { mac_version: req_dp.mac_version().from_proto(), reg_params_revision: req_dp.reg_params_revision().from_proto(), adr_algorithm_id: req_dp.adr_algorithm_id.clone(), + codec_plugin_id: req_dp.codec_plugin_id.clone(), payload_codec_runtime: req_dp.payload_codec_runtime().from_proto(), payload_codec_script: req_dp.payload_codec_script.clone(), flush_queue_on_activate: req_dp.flush_queue_on_activate, @@ -338,6 +341,7 @@ pub mod test { mac_version: common::MacVersion::Lorawan103.into(), reg_params_revision: common::RegParamsRevision::A.into(), adr_algorithm_id: "default".into(), + codec_plugin_id: "passthrough".into(), ..Default::default() }), }, @@ -362,6 +366,7 @@ pub mod test { mac_version: common::MacVersion::Lorawan103.into(), reg_params_revision: common::RegParamsRevision::A.into(), adr_algorithm_id: "default".into(), + codec_plugin_id: "passthrough".into(), ..Default::default() }), get_resp.get_ref().device_profile_template @@ -380,6 +385,7 @@ pub mod test { mac_version: common::MacVersion::Lorawan103.into(), reg_params_revision: common::RegParamsRevision::A.into(), adr_algorithm_id: "default".into(), + codec_plugin_id: "passthrough".into(), ..Default::default() }), }, @@ -404,6 +410,7 @@ pub mod test { mac_version: common::MacVersion::Lorawan103.into(), reg_params_revision: common::RegParamsRevision::A.into(), adr_algorithm_id: "default".into(), + codec_plugin_id: "passthrough".into(), ..Default::default() }), get_resp.get_ref().device_profile_template diff --git a/chirpstack/src/api/helpers.rs b/chirpstack/src/api/helpers.rs index 96243c01..8ac45d4d 100644 --- a/chirpstack/src/api/helpers.rs +++ b/chirpstack/src/api/helpers.rs @@ -133,6 +133,7 @@ impl ToProto for Codec { Codec::NONE => api::CodecRuntime::None, Codec::CAYENNE_LPP => api::CodecRuntime::CayenneLpp, Codec::JS => api::CodecRuntime::Js, + Codec::JS_PLUGIN => api::CodecRuntime::JsPlugin, } } } @@ -143,6 +144,7 @@ impl FromProto for api::CodecRuntime { api::CodecRuntime::None => Codec::NONE, api::CodecRuntime::CayenneLpp => Codec::CAYENNE_LPP, api::CodecRuntime::Js => Codec::JS, + api::CodecRuntime::JsPlugin => Codec::JS_PLUGIN, } } } diff --git a/chirpstack/src/cmd/configfile.rs b/chirpstack/src/cmd/configfile.rs index a86fb477..839f8f21 100644 --- a/chirpstack/src/cmd/configfile.rs +++ b/chirpstack/src/cmd/configfile.rs @@ -573,6 +573,16 @@ pub fn run() { # Maximum execution time. max_execution_time="{{ codec.js.max_execution_time }}" + # Custom codec plugins. + # + # The custom codec plugin must be implemented in JavaScript. For an example + # skeleton, please see file examples/custom_plugins/plugin_skeleton.js + plugins=[ + {{#each codec.js.plugins}} + "{{this}}", + {{/each}} + ] + # User authentication configuration. [user_authentication] diff --git a/chirpstack/src/cmd/root.rs b/chirpstack/src/cmd/root.rs index e44bca17..860fa6ab 100644 --- a/chirpstack/src/cmd/root.rs +++ b/chirpstack/src/cmd/root.rs @@ -5,7 +5,7 @@ use signal_hook_tokio::Signals; use tracing::{info, warn}; use crate::gateway; -use crate::{adr, api, applayer::fuota, backend, downlink, integration, region, storage}; +use crate::{adr, api, applayer::fuota, backend, codec, downlink, integration, region, storage}; pub async fn run() -> Result<()> { info!( @@ -18,6 +18,7 @@ pub async fn run() -> Result<()> { region::setup()?; backend::setup().await?; adr::setup().await?; + codec::js_plugin::setup().await?; integration::setup().await?; gateway::backend::setup().await?; downlink::setup().await; diff --git a/chirpstack/src/codec/js/mod.rs b/chirpstack/src/codec/js/mod.rs index 9e5fd574..2489d70e 100644 --- a/chirpstack/src/codec/js/mod.rs +++ b/chirpstack/src/codec/js/mod.rs @@ -8,9 +8,9 @@ use rquickjs::{CatchResultExt, IntoJs}; use super::convert; use crate::config; -mod vendor_base64_js; -mod vendor_buffer; -mod vendor_ieee754; +pub mod vendor_base64_js; +pub mod vendor_buffer; +pub mod vendor_ieee754; pub async fn decode( recv_time: DateTime, diff --git a/chirpstack/src/codec/js_plugin/mod.rs b/chirpstack/src/codec/js_plugin/mod.rs new file mode 100644 index 00000000..08732021 --- /dev/null +++ b/chirpstack/src/codec/js_plugin/mod.rs @@ -0,0 +1,109 @@ +use crate::config; +use anyhow::Result; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use std::collections::HashMap; +use std::sync::LazyLock; +use tokio::sync::RwLock; +use tracing::{info, trace, warn}; + +pub mod passthrough; +pub mod plugin; + +static CODEC_PLUGINS: LazyLock>>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +pub async fn setup() -> Result<()> { + info!("Setting up codec plugins"); + let mut plugins = CODEC_PLUGINS.write().await; + + trace!("Setting up included algorithms"); + let a = plugin::Plugin::default()?; + plugins.insert(a.get_id(), Box::new(a)); + + trace!("Setting up provided codec plugins"); + let conf = config::get(); + for file_path in &conf.codec.js.plugins { + info!(file_path = %file_path, "Setting up codec plugin"); + let a = plugin::Plugin::new(file_path)?; + plugins.insert(a.get_id(), Box::new(a)); + } + + Ok(()) +} + +pub async fn get_plugins() -> HashMap { + let mut out: HashMap = HashMap::new(); + + let plugins = CODEC_PLUGINS.read().await; + for (_, v) in plugins.iter() { + out.insert(v.get_id(), v.get_name()); + } + + out +} + +pub async fn encode( + plugin_id: &str, + f_port: u8, + variables: &HashMap, + obj: &prost_types::Struct, +) -> Result> { + let plugins = CODEC_PLUGINS.read().await; + match plugins.get(plugin_id) { + Some(v) => v.encode(f_port, variables, obj).await, + None => { + warn!(plugin_id = %plugin_id, "No codec plugin configured with given ID"); + Err(anyhow!( + "No codec plugin configured with given ID: {}", + plugin_id + )) + } + } +} + +pub async fn decode( + plugin_id: &str, + recv_time: DateTime, + f_port: u8, + variables: &HashMap, + b: &[u8], +) -> Result { + let plugins = CODEC_PLUGINS.read().await; + match plugins.get(plugin_id) { + Some(v) => v.decode(recv_time, f_port, variables, b).await, + None => { + warn!(plugin_id = %plugin_id, "No codec plugin configured with given ID"); + Err(anyhow!( + "No codec plugin configured with given ID: {}", + plugin_id + )) + } + } +} + +#[async_trait] +pub trait Handler { + // Returns the name. + fn get_name(&self) -> String; + + // Get the ID. + fn get_id(&self) -> String; + + // Encode downlink + async fn encode( + &self, + f_port: u8, + variables: &HashMap, + obj: &prost_types::Struct, + ) -> Result>; + + // Decode uplink + async fn decode( + &self, + recv_time: DateTime, + f_port: u8, + variables: &HashMap, + b: &[u8], + ) -> Result; +} diff --git a/chirpstack/src/codec/js_plugin/passthrough.rs b/chirpstack/src/codec/js_plugin/passthrough.rs new file mode 100644 index 00000000..723bc7c9 --- /dev/null +++ b/chirpstack/src/codec/js_plugin/passthrough.rs @@ -0,0 +1,37 @@ +// passthrough default codec +// Performs nothing with the uplinks, and passthrough downlinks + +pub const SCRIPT: &str = r#" +/** + * Decode uplink function + * + * @param {object} input + * @param {number[]} input.bytes Byte array containing the uplink payload, e.g. [255, 230, 255, 0] + * @param {number} input.fPort Uplink fPort. + * @param {Record} input.variables Object containing the configured device variables. + * + * @returns {{data: object}} Object representing the decoded payload. + */ +export function decodeUplink(input) { + return { + data: { + // Empty object + } + }; +} + +/** + * Encode downlink function. + * + * @param {object} input + * @param {object} input.data Object representing the payload that must be encoded. + * @param {Record} input.variables Object containing the configured device variables. + * + * @returns {{bytes: number[]}} Byte array containing the downlink payload. + */ +export function encodeDownlink(input) { + return { + bytes: input.data, // Passthrough + }; +} +"#; diff --git a/chirpstack/src/codec/js_plugin/plugin.rs b/chirpstack/src/codec/js_plugin/plugin.rs new file mode 100644 index 00000000..a56b64ba --- /dev/null +++ b/chirpstack/src/codec/js_plugin/plugin.rs @@ -0,0 +1,528 @@ +use std::collections::HashMap; +use std::fs; +use std::time::SystemTime; + +use super::{passthrough, Handler}; +use anyhow::{Context, Result}; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; + +use rquickjs::{CatchResultExt, IntoJs}; + +use super::super::convert; +use super::super::js::vendor_base64_js; +use super::super::js::vendor_buffer; +use super::super::js::vendor_ieee754; + +use crate::config; + +pub struct Plugin { + script: String, + id: String, + name: String, +} + +impl Plugin { + pub fn new(file_path: &str) -> Result { + let script = fs::read_to_string(file_path).context("Read codec plugin")?; + + Plugin::from_string(script) + } + + pub fn from_string(script: String) -> Result { + let rt = rquickjs::Runtime::new()?; + let ctx = rquickjs::Context::full(&rt)?; + + let (id, name) = ctx.with::<_, Result<(String, String)>>(|ctx| { + let m = rquickjs::Module::declare(ctx, "script", script.clone()) + .context("Declare script")?; + let (m, m_promise) = m.eval().context("Evaluate script")?; + () = m_promise.finish()?; + let id_func: rquickjs::Function = m.get("id").context("Get id function")?; + let name_func: rquickjs::Function = m.get("name").context("Get name function")?; + + let id: String = id_func.call(()).context("Call id function")?; + let name: String = name_func.call(()).context("Call name function")?; + + Ok((id, name)) + })?; + + let p = Plugin { script, id, name }; + + Ok(p) + } + + pub fn default() -> Result { + let p = Plugin { + script: passthrough::SCRIPT.to_string(), + id: String::from("passthrough"), + name: String::from("Passthrough"), + }; + + Ok(p) + } +} + +#[async_trait] +impl Handler for Plugin { + fn get_name(&self) -> String { + self.name.clone() + } + + fn get_id(&self) -> String { + self.id.clone() + } + + async fn encode( + &self, + f_port: u8, + variables: &HashMap, + obj: &prost_types::Struct, + ) -> Result> { + let conf = config::get(); + let max_run_ts = SystemTime::now() + conf.codec.js.max_execution_time; + + let resolver = rquickjs::loader::BuiltinResolver::default() + .with_module("base64-js") + .with_module("ieee754") + .with_module("buffer"); + let loader = rquickjs::loader::BuiltinLoader::default() + .with_module("base64-js", vendor_base64_js::SCRIPT) + .with_module("ieee754", vendor_ieee754::SCRIPT) + .with_module("buffer", vendor_buffer::SCRIPT); + + let rt = rquickjs::Runtime::new()?; + rt.set_interrupt_handler(Some(Box::new(move || SystemTime::now() > max_run_ts))); + rt.set_loader(resolver, loader); + + let ctx = rquickjs::Context::full(&rt)?; + + ctx.with::<_, Result>>(|ctx| { + // We need to export the Buffer class, so it is correctly resolved + // in called encode/decode functions + let buff = rquickjs::Module::declare( + ctx.clone(), + "b", + r#" + import { Buffer } from "buffer"; + export { Buffer } + "#, + ) + .context("Declare script")?; + let (buff, buff_promise) = buff + .eval() + .catch(&ctx) + .map_err(|e| anyhow!("JS plugin error: {}", e))?; + () = buff_promise.finish()?; + let buff: rquickjs::Function = buff.get("Buffer")?; + + let m = rquickjs::Module::declare(ctx.clone(), "script", self.script.clone()) + .context("Declare script")?; + let (m, m_promise) = m.eval().context("Evaluate script")?; + () = m_promise.finish()?; + let func: rquickjs::Function = m + .get("encodeDownlink") + .context("Get encodeDownlink function")?; + + let input = rquickjs::Object::new(ctx.clone())?; + input.set("fPort", f_port.into_js(&ctx)?)?; + input.set("variables", variables.into_js(&ctx)?)?; + input.set("data", convert::struct_to_rquickjs(&ctx, obj))?; + + let globals = ctx.globals(); + globals.set("Buffer", buff)?; + + let res: rquickjs::Object = func + .call((input,)) + .catch(&ctx) + .map_err(|e| anyhow!("JS plugin error: {}", e))?; + + let errors: Result, rquickjs::Error> = res.get("errors"); + if let Ok(errors) = errors { + if !errors.is_empty() { + return Err(anyhow!( + "encodeDownlink returned errors: {}", + errors.join(", ") + )); + } + } + + // Directly into u8 can result into the following error: + // Error converting from js 'float' into type 'i32' + let v: Vec = res.get("bytes")?; + let v: Vec = v.iter().map(|v| *v as u8).collect(); + + Ok(v) + }) + } + + async fn decode( + &self, + recv_time: DateTime, + f_port: u8, + variables: &HashMap, + b: &[u8], + ) -> Result { + let conf = config::get(); + let max_run_ts = SystemTime::now() + conf.codec.js.max_execution_time; + + let resolver = rquickjs::loader::BuiltinResolver::default() + .with_module("base64-js") + .with_module("ieee754") + .with_module("buffer"); + let loader = rquickjs::loader::BuiltinLoader::default() + .with_module("base64-js", vendor_base64_js::SCRIPT) + .with_module("ieee754", vendor_ieee754::SCRIPT) + .with_module("buffer", vendor_buffer::SCRIPT); + + let rt = rquickjs::Runtime::new()?; + rt.set_interrupt_handler(Some(Box::new(move || SystemTime::now() > max_run_ts))); + rt.set_loader(resolver, loader); + + let ctx = rquickjs::Context::full(&rt)?; + + let out = ctx.with(|ctx| -> Result { + // We need to export the Buffer class, so it is correctly resolved + // in called encode/decode functions + let buff = rquickjs::Module::declare( + ctx.clone(), + "b", + r#" + import { Buffer } from "buffer"; + export { Buffer } + "#, + ) + .context("Declare script")?; + let (buff, buff_promise) = buff + .eval() + .catch(&ctx) + .map_err(|e| anyhow!("JS plugin error: {}", e))?; + () = buff_promise.finish()?; + let buff: rquickjs::Function = buff.get("Buffer")?; + + let m = rquickjs::Module::declare(ctx.clone(), "script", self.script.clone()) + .context("Declare script")?; + let (m, m_promise) = m.eval().context("Evaluate script")?; + () = m_promise.finish()?; + let func: rquickjs::Function = + m.get("decodeUplink").context("Get decodeUplink function")?; + + let input = rquickjs::Object::new(ctx.clone())?; + input.set("bytes", b.into_js(&ctx)?)?; + input.set("fPort", f_port.into_js(&ctx)?)?; + input.set("recvTime", recv_time.into_js(&ctx)?)?; + input.set("variables", variables.into_js(&ctx)?)?; + + let globals = ctx.globals(); + globals.set("Buffer", buff)?; + + let res: rquickjs::Object = func + .call((input,)) + .catch(&ctx) + .map_err(|e| anyhow!("JS plugin error: {}", e))?; + + let errors: Result, rquickjs::Error> = res.get("errors"); + if let Ok(errors) = errors { + if !errors.is_empty() { + return Err(anyhow!( + "decodeUplink returned errors: {}", + errors.join(", ") + )); + } + } + + Ok(convert::rquickjs_to_struct(&res)) + })?; + + let data = out.fields.get("data").cloned().unwrap_or_default(); + if let Some(pbjson_types::value::Kind::StructValue(v)) = data.kind { + return Ok(v); + } + + Err(anyhow!("decodeUplink did not return 'data'")) + } +} + +pub mod test { + use super::*; + use chrono::TimeZone; + use chrono::Utc; + + #[tokio::test] + async fn test_plugin() { + let p = Plugin::new("../examples/codec_plugins/plugin_skeleton.js").unwrap(); + + assert_eq!("Example plugin", p.get_name()); + assert_eq!("example_id", p.get_id()); + } + + #[tokio::test] + pub async fn test_decode_timeout() { + let script = r#" + export function id() { + return "test_decode_timeout"; + } + + export function name() { + return "test_decode_timeout"; + } + + export function decodeUplink(input) { + while (true) { + + } + } + "# + .to_string(); + + let p = Plugin::from_string(script).unwrap(); + + let vars: HashMap = HashMap::new(); + let out = p.decode(Utc::now(), 10, &vars, &[0x01, 0x02, 0x03]).await; + + assert!(out.is_err()); + } + + #[tokio::test] + pub async fn test_decode_error() { + let script = r#" + export function id() { + return "test_decode_error"; + } + + export function name() { + return "test_decode_error"; + } + + export function decodeUplink(input) { + return foo; + } + "# + .to_string(); + + let p = Plugin::from_string(script).unwrap(); + + let vars: HashMap = HashMap::new(); + let out = p.decode(Utc::now(), 10, &vars, &[0x01, 0x02, 0x03]).await; + + assert_eq!( + "JS plugin error: Error: foo is not defined\n at decodeUplink (script:10:1)\n", + out.err().unwrap().to_string() + ); + } + + #[tokio::test] + pub async fn test_decode() { + let recv_time = Utc.with_ymd_and_hms(2014, 7, 8, 9, 10, 11).unwrap(); + + let script = r#" + export function id() { + return "test_decode"; + } + + export function name() { + return "test_decode"; + } + + export function decodeUplink(input) { + var buff = new Buffer(input.bytes); + + return { + data: { + f_port: input.fPort, + variables: input.variables, + data_hex: buff.toString('hex'), + data: input.bytes, + recv_time: input.recvTime.toString() + } + }; + } + "# + .to_string(); + + let p = Plugin::from_string(script).unwrap(); + + let mut vars: HashMap = HashMap::new(); + vars.insert("foo".into(), "bar".into()); + + let out = p + .decode(recv_time, 10, &vars, &[0x01, 0x02, 0x03]) + .await + .unwrap(); + + let expected = pbjson_types::Struct { + fields: [ + ( + "f_port".to_string(), + pbjson_types::Value { + kind: Some(pbjson_types::value::Kind::NumberValue(10.0)), + }, + ), + ( + "variables".to_string(), + pbjson_types::Value { + kind: Some(pbjson_types::value::Kind::StructValue( + pbjson_types::Struct { + fields: [( + "foo".to_string(), + pbjson_types::Value { + kind: Some(pbjson_types::value::Kind::StringValue( + "bar".to_string(), + )), + }, + )] + .iter() + .cloned() + .collect(), + }, + )), + }, + ), + ( + "data_hex".to_string(), + pbjson_types::Value { + kind: Some(pbjson_types::value::Kind::StringValue("010203".to_string())), + }, + ), + ( + "data".to_string(), + pbjson_types::Value { + kind: Some(pbjson_types::value::Kind::ListValue( + pbjson_types::ListValue { + values: vec![ + pbjson_types::Value { + kind: Some(pbjson_types::value::Kind::NumberValue(1.0)), + }, + pbjson_types::Value { + kind: Some(pbjson_types::value::Kind::NumberValue(2.0)), + }, + pbjson_types::Value { + kind: Some(pbjson_types::value::Kind::NumberValue(3.0)), + }, + ], + }, + )), + }, + ), + ( + "recv_time".to_string(), + pbjson_types::Value { + kind: Some(pbjson_types::value::Kind::StringValue( + "Tue Jul 08 2014 09:10:11 GMT+0000".to_string(), + )), + }, + ), + ] + .iter() + .cloned() + .collect(), + }; + + assert_eq!(expected, out); + } + + #[tokio::test] + pub async fn test_encode_timeout() { + let script = r#" + export function id() { + return "test_encode_timeout"; + } + + export function name() { + return "test_encode_timeout"; + } + + export function encodeDownlink(input) { + while (true) { + + } + } + "# + .to_string(); + + let p = Plugin::from_string(script).unwrap(); + + let vars: HashMap = HashMap::new(); + + let input = prost_types::Struct { + ..Default::default() + }; + + let out = p.encode(10, &vars, &input).await; + assert!(out.is_err()); + } + + #[tokio::test] + pub async fn test_encode_error() { + let script = r#" + export function id() { + return "test_encode_error"; + } + + export function name() { + return "test_encode_error"; + } + + export function encodeDownlink(input) { + return foo; + } + "# + .to_string(); + + let p = Plugin::from_string(script).unwrap(); + + let vars: HashMap = HashMap::new(); + + let input = prost_types::Struct { + ..Default::default() + }; + + let out = p.encode(10, &vars, &input).await; + assert_eq!( + "JS plugin error: Error: foo is not defined\n at encodeDownlink (script:10:1)\n", + out.err().unwrap().to_string() + ); + } + + #[tokio::test] + pub async fn test_encode() { + let script = r#" + export function id() { + return "test_encode"; + } + + export function name() { + return "test_encode"; + } + + export function encodeDownlink(input) { + if (input.data.enabled) { + return { + bytes: [0x01] + }; + } else { + return { + bytes: [0x02] + }; + } + } + "# + .to_string(); + + let p = Plugin::from_string(script).unwrap(); + + let mut vars: HashMap = HashMap::new(); + vars.insert("foo".into(), "bar".into()); + + let mut input = prost_types::Struct::default(); + input.fields.insert( + "enabled".to_string(), + prost_types::Value { + kind: Some(prost_types::value::Kind::BoolValue(true)), + }, + ); + + let out = p.encode(10, &vars, &input).await.unwrap(); + assert_eq!(vec![1], out); + } +} diff --git a/chirpstack/src/codec/mod.rs b/chirpstack/src/codec/mod.rs index 5b84b8ef..15ddfc1f 100644 --- a/chirpstack/src/codec/mod.rs +++ b/chirpstack/src/codec/mod.rs @@ -16,6 +16,7 @@ use serde::{Deserialize, Serialize}; mod cayenne_lpp; pub mod convert; mod js; +pub mod js_plugin; #[derive(Deserialize, Serialize, Copy, Clone, Debug, Eq, PartialEq, AsExpression, FromSqlRow)] #[allow(non_camel_case_types, clippy::upper_case_acronyms)] @@ -24,6 +25,7 @@ pub enum Codec { NONE, CAYENNE_LPP, JS, + JS_PLUGIN, } impl fmt::Display for Codec { @@ -69,6 +71,7 @@ impl FromStr for Codec { "" | "NONE" => Codec::NONE, "CAYENNE_LPP" => Codec::CAYENNE_LPP, "JS" => Codec::JS, + "JS_PLUGIN" => Codec::JS_PLUGIN, _ => { return Err(anyhow!("Unexpected codec: {}", s)); } @@ -82,12 +85,16 @@ pub async fn binary_to_struct( f_port: u8, variables: &HashMap, decoder_config: &str, + codec_plugin_id: &str, b: &[u8], ) -> Result> { Ok(match codec { Codec::NONE => None, Codec::CAYENNE_LPP => Some(cayenne_lpp::decode(b).context("CayenneLpp decode")?), Codec::JS => Some(js::decode(recv_time, f_port, variables, decoder_config, b).await?), + Codec::JS_PLUGIN => { + Some(js_plugin::decode(codec_plugin_id, recv_time, f_port, variables, b).await?) + } }) } @@ -96,12 +103,14 @@ pub async fn struct_to_binary( f_port: u8, variables: &HashMap, encoder_config: &str, + codec_plugin_id: &str, obj: &prost_types::Struct, ) -> Result> { Ok(match codec { Codec::NONE => Vec::new(), Codec::CAYENNE_LPP => cayenne_lpp::encode(obj).context("CayenneLpp encode")?, Codec::JS => js::encode(f_port, variables, encoder_config, obj).await?, + Codec::JS_PLUGIN => js_plugin::encode(codec_plugin_id, f_port, variables, obj).await?, }) } diff --git a/chirpstack/src/config.rs b/chirpstack/src/config.rs index 6b2d8dfc..518b3d2f 100644 --- a/chirpstack/src/config.rs +++ b/chirpstack/src/config.rs @@ -405,12 +405,14 @@ pub struct Codec { pub struct CodecJs { #[serde(with = "humantime_serde")] pub max_execution_time: Duration, + pub plugins: Vec, } impl Default for CodecJs { fn default() -> Self { CodecJs { max_execution_time: Duration::from_millis(100), + plugins: vec![], } } } diff --git a/chirpstack/src/integration/mod.rs b/chirpstack/src/integration/mod.rs index 34da2bde..cbb537ff 100644 --- a/chirpstack/src/integration/mod.rs +++ b/chirpstack/src/integration/mod.rs @@ -551,6 +551,7 @@ async fn handle_down_command(application_id: String, pl: integration::DownlinkCo pl.f_port as u8, &dev.variables, &dp.payload_codec_script, + &dp.codec_plugin_id, &codec::convert::pb_json_to_prost(obj), ) .await?; diff --git a/chirpstack/src/storage/device_profile.rs b/chirpstack/src/storage/device_profile.rs index aae8ce4a..47a01ca5 100644 --- a/chirpstack/src/storage/device_profile.rs +++ b/chirpstack/src/storage/device_profile.rs @@ -28,6 +28,7 @@ pub struct DeviceProfile { pub mac_version: MacVersion, pub reg_params_revision: Revision, pub adr_algorithm_id: String, + pub codec_plugin_id: String, pub payload_codec_runtime: Codec, pub uplink_interval: i32, pub device_status_req_interval: i32, @@ -79,6 +80,7 @@ impl Default for DeviceProfile { mac_version: MacVersion::LORAWAN_1_0_0, reg_params_revision: Revision::A, adr_algorithm_id: "".into(), + codec_plugin_id: "".into(), payload_codec_runtime: Codec::NONE, payload_codec_script: "".into(), flush_queue_on_activate: false, @@ -192,6 +194,7 @@ pub async fn update(dp: DeviceProfile) -> Result { device_profile::mac_version.eq(&dp.mac_version), device_profile::reg_params_revision.eq(&dp.reg_params_revision), device_profile::adr_algorithm_id.eq(&dp.adr_algorithm_id), + device_profile::codec_plugin_id.eq(&dp.codec_plugin_id), device_profile::payload_codec_runtime.eq(&dp.payload_codec_runtime), device_profile::payload_codec_script.eq(&dp.payload_codec_script), device_profile::flush_queue_on_activate.eq(&dp.flush_queue_on_activate), @@ -342,6 +345,7 @@ pub mod test { mac_version: MacVersion::LORAWAN_1_0_2, reg_params_revision: Revision::B, adr_algorithm_id: "default".into(), + codec_plugin_id: "passthrough".into(), payload_codec_runtime: Codec::JS, uplink_interval: 60, supports_otaa: true, diff --git a/chirpstack/src/storage/device_profile_template.rs b/chirpstack/src/storage/device_profile_template.rs index 6d082137..d0b175d7 100644 --- a/chirpstack/src/storage/device_profile_template.rs +++ b/chirpstack/src/storage/device_profile_template.rs @@ -28,6 +28,7 @@ pub struct DeviceProfileTemplate { pub mac_version: MacVersion, pub reg_params_revision: Revision, pub adr_algorithm_id: String, + pub codec_plugin_id: String, pub payload_codec_runtime: Codec, pub payload_codec_script: String, pub uplink_interval: i32, @@ -91,6 +92,7 @@ impl Default for DeviceProfileTemplate { mac_version: MacVersion::LORAWAN_1_0_0, reg_params_revision: Revision::A, adr_algorithm_id: "".into(), + codec_plugin_id: "".into(), payload_codec_runtime: Codec::NONE, payload_codec_script: "".into(), uplink_interval: 0, @@ -160,6 +162,7 @@ pub async fn upsert(dp: DeviceProfileTemplate) -> Result Result Varchar, #[max_length = 100] adr_algorithm_id -> Varchar, + #[max_length = 100] + codec_plugin_id -> Varchar, #[max_length = 20] payload_codec_runtime -> Varchar, uplink_interval -> Int4, @@ -143,6 +145,8 @@ diesel::table! { reg_params_revision -> Varchar, #[max_length = 100] adr_algorithm_id -> Varchar, + #[max_length = 100] + codec_plugin_id -> Varchar, #[max_length = 20] payload_codec_runtime -> Varchar, payload_codec_script -> Text, diff --git a/chirpstack/src/storage/schema_sqlite.rs b/chirpstack/src/storage/schema_sqlite.rs index 2022b714..fd1e657c 100644 --- a/chirpstack/src/storage/schema_sqlite.rs +++ b/chirpstack/src/storage/schema_sqlite.rs @@ -88,6 +88,7 @@ diesel::table! { mac_version -> Text, reg_params_revision -> Text, adr_algorithm_id -> Text, + codec_plugin_id -> Text, payload_codec_runtime -> Text, uplink_interval -> Integer, device_status_req_interval -> Integer, @@ -124,6 +125,7 @@ diesel::table! { mac_version -> Text, reg_params_revision -> Text, adr_algorithm_id -> Text, + codec_plugin_id -> Text, payload_codec_runtime -> Text, payload_codec_script -> Text, uplink_interval -> Integer, diff --git a/chirpstack/src/test/mod.rs b/chirpstack/src/test/mod.rs index 8cbd763d..58e31997 100644 --- a/chirpstack/src/test/mod.rs +++ b/chirpstack/src/test/mod.rs @@ -1,7 +1,7 @@ use std::env; use std::sync::{LazyLock, Mutex, Once}; -use crate::{adr, config, region, storage}; +use crate::{adr, codec, config, region, storage}; mod assert; mod class_a_pr_test; @@ -90,5 +90,8 @@ pub async fn prepare<'a>() -> std::sync::MutexGuard<'a, ()> { // setup adr adr::setup().await.unwrap(); + // setup codec plugins + codec::js_plugin::setup().await.unwrap(); + guard } diff --git a/chirpstack/src/uplink/data.rs b/chirpstack/src/uplink/data.rs index bccb7135..4ccf3ffa 100644 --- a/chirpstack/src/uplink/data.rs +++ b/chirpstack/src/uplink/data.rs @@ -964,6 +964,7 @@ impl Data { mac.f_port.unwrap_or(0), &dev.variables, &dp.payload_codec_script, + &dp.codec_plugin_id, &pl.data, ) .await diff --git a/examples/codec_plugins/plugin_skeleton.js b/examples/codec_plugins/plugin_skeleton.js new file mode 100644 index 00000000..74166ed9 --- /dev/null +++ b/examples/codec_plugins/plugin_skeleton.js @@ -0,0 +1,42 @@ +// This must return the name of the codec plugin. +export function name() { + return "Example plugin"; +} + +// This must return the id of the codec plugin. +export function id() { + return "example_id"; +} + +/** + * Decode uplink function + * + * @param {object} input + * @param {number[]} input.bytes Byte array containing the uplink payload, e.g. [255, 230, 255, 0] + * @param {number} input.fPort Uplink fPort. + * @param {Record} input.variables Object containing the configured device variables. + * + * @returns {{data: object}} Object representing the decoded payload. + */ +export function decodeUplink(input) { + return { + data: { + // temp: 22.5 + } + }; +} + +/** + * Encode downlink function. + * + * @param {object} input + * @param {object} input.data Object representing the payload that must be encoded. + * @param {Record} input.variables Object containing the configured device variables. + * + * @returns {{bytes: number[]}} Byte array containing the downlink payload. + */ +export function encodeDownlink(input) { + return { + bytes: input.data // Passthrough + }; +} diff --git a/ui/src/stores/DeviceProfileStore.ts b/ui/src/stores/DeviceProfileStore.ts index 8911d507..a65b4925 100644 --- a/ui/src/stores/DeviceProfileStore.ts +++ b/ui/src/stores/DeviceProfileStore.ts @@ -12,6 +12,7 @@ import type { ListDeviceProfilesRequest, ListDeviceProfilesResponse, ListDeviceProfileAdrAlgorithmsResponse, + ListDeviceProfileCodecPluginsResponse, } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb"; import SessionStore from "./SessionStore"; @@ -105,6 +106,17 @@ class DeviceProfileStore extends EventEmitter { callbackFunc(resp); }); }; + + listCodecPlugins = (callbackFunc: (resp: ListDeviceProfileCodecPluginsResponse) => void) => { + this.client.listCodecPlugins(new google_protobuf_empty_pb.Empty(), SessionStore.getMetadata(), (err, resp) => { + if (err !== null) { + HandleError(err); + return; + } + + callbackFunc(resp); + }); + }; } const deviceProfileStore = new DeviceProfileStore(); diff --git a/ui/src/views/device-profile-templates/CreateDeviceProfileTemplate.tsx b/ui/src/views/device-profile-templates/CreateDeviceProfileTemplate.tsx index 875f223a..e927fcac 100644 --- a/ui/src/views/device-profile-templates/CreateDeviceProfileTemplate.tsx +++ b/ui/src/views/device-profile-templates/CreateDeviceProfileTemplate.tsx @@ -66,6 +66,7 @@ function encodeDownlink(input) { deviceProfileTemplate.setUplinkInterval(3600); deviceProfileTemplate.setDeviceStatusReqInterval(1); deviceProfileTemplate.setAdrAlgorithmId("default"); + deviceProfileTemplate.setCodecPluginId("passthrough"); deviceProfileTemplate.setMacVersion(MacVersion.LORAWAN_1_0_3); deviceProfileTemplate.setRegParamsRevision(RegParamsRevision.A); deviceProfileTemplate.setFlushQueueOnActivate(true); diff --git a/ui/src/views/device-profile-templates/DeviceProfileTemplateForm.tsx b/ui/src/views/device-profile-templates/DeviceProfileTemplateForm.tsx index 32a8e3e7..57893af4 100644 --- a/ui/src/views/device-profile-templates/DeviceProfileTemplateForm.tsx +++ b/ui/src/views/device-profile-templates/DeviceProfileTemplateForm.tsx @@ -1,3 +1,5 @@ +// TODO: show sha hash of the plugin script? + import { useState, useEffect } from "react"; import { Form, Input, Select, InputNumber, Switch, Row, Col, Button, Tabs, Card } from "antd"; @@ -7,6 +9,7 @@ import { DeviceProfileTemplate } from "@chirpstack/chirpstack-api-grpc-web/api/d import { CodecRuntime, Measurement, MeasurementKind } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb"; import { Region, MacVersion, RegParamsRevision } from "@chirpstack/chirpstack-api-grpc-web/common/common_pb"; import type { ListDeviceProfileAdrAlgorithmsResponse } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb"; +import type { ListDeviceProfileCodecPluginsResponse } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb"; import { onFinishFailed } from "../helpers"; import DeviceProfileStore from "../../stores/DeviceProfileStore"; @@ -25,6 +28,7 @@ function DeviceProfileTemplateForm(props: IProps) { const [supportsClassC, setSupportsClassC] = useState(false); const [payloadCodecRuntime, setPayloadCodecRuntime] = useState(CodecRuntime.NONE); const [adrAlgorithms, setAdrAlgorithms] = useState<[string, string][]>([]); + const [codecPlugins, setCodecPlugins] = useState<[string, string][]>([]); useEffect(() => { const v = props.initialValues; @@ -41,6 +45,15 @@ function DeviceProfileTemplateForm(props: IProps) { setAdrAlgorithms(adrAlgorithms); }); + + DeviceProfileStore.listCodecPlugins((resp: ListDeviceProfileCodecPluginsResponse) => { + const codecPlugins: [string, string][] = []; + for (const a of resp.getResultList()) { + codecPlugins.push([a.getId(), a.getName()]); + } + + setCodecPlugins(codecPlugins); + }); }, [props.initialValues]); const onFinish = (values: DeviceProfileTemplate.AsObject) => { @@ -81,6 +94,7 @@ function DeviceProfileTemplateForm(props: IProps) { // codec dp.setPayloadCodecRuntime(v.payloadCodecRuntime); dp.setPayloadCodecScript(v.payloadCodecScript); + dp.setCodecPluginId(v.codecPluginId); // tags for (const elm of v.tagsMap) { @@ -116,6 +130,7 @@ function DeviceProfileTemplateForm(props: IProps) { }; const adrOptions = adrAlgorithms.map(v => {v[1]}); + const codecPluginsOptions = codecPlugins.map(v => {v[1]}); return (
None Cayenne LPP JavaScript functions + JavaScript plugin + {payloadCodecRuntime === CodecRuntime.JS_PLUGIN && ( + + + + )} {payloadCodecRuntime === CodecRuntime.JS && } diff --git a/ui/src/views/device-profiles/CreateDeviceProfile.tsx b/ui/src/views/device-profiles/CreateDeviceProfile.tsx index 562edba4..a83a34c1 100644 --- a/ui/src/views/device-profiles/CreateDeviceProfile.tsx +++ b/ui/src/views/device-profiles/CreateDeviceProfile.tsx @@ -76,6 +76,7 @@ function encodeDownlink(input) { deviceProfile.setUplinkInterval(3600); deviceProfile.setDeviceStatusReqInterval(1); deviceProfile.setAdrAlgorithmId("default"); + deviceProfile.setCodecPluginId("passthrough"); deviceProfile.setMacVersion(MacVersion.LORAWAN_1_0_3); deviceProfile.setRegParamsRevision(RegParamsRevision.A); deviceProfile.setFlushQueueOnActivate(true); diff --git a/ui/src/views/device-profiles/DeviceProfileForm.tsx b/ui/src/views/device-profiles/DeviceProfileForm.tsx index 75d85f70..3b845d2d 100644 --- a/ui/src/views/device-profiles/DeviceProfileForm.tsx +++ b/ui/src/views/device-profiles/DeviceProfileForm.tsx @@ -1,3 +1,5 @@ +// TODO: show sha hash of the plugin script? + import { useState, useEffect } from "react"; import { Form, Input, Select, InputNumber, Switch, Row, Col, Button, Tabs, Modal, Spin, Cascader, Card } from "antd"; @@ -19,6 +21,7 @@ import { import { Region, MacVersion, RegParamsRevision } from "@chirpstack/chirpstack-api-grpc-web/common/common_pb"; import type { ListRegionsResponse, RegionListItem } from "@chirpstack/chirpstack-api-grpc-web/api/internal_pb"; import type { ListDeviceProfileAdrAlgorithmsResponse } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb"; +import type { ListDeviceProfileCodecPluginsResponse } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb"; import type { ListDeviceProfileTemplatesResponse, GetDeviceProfileTemplateResponse, @@ -189,6 +192,7 @@ function DeviceProfileForm(props: IProps) { const [isRelayEd, setIsRelayEd] = useState(false); const [payloadCodecRuntime, setPayloadCodecRuntime] = useState(CodecRuntime.NONE); const [adrAlgorithms, setAdrAlgorithms] = useState<[string, string][]>([]); + const [codecPlugins, setCodecPlugins] = useState<[string, string][]>([]); const [regionConfigurations, setRegionConfigurations] = useState([]); const [regionConfigurationsFiltered, setRegionConfigurationsFiltered] = useState<[string, string][]>([]); const [templateModalVisible, setTemplateModalVisible] = useState(false); @@ -224,6 +228,15 @@ function DeviceProfileForm(props: IProps) { setAdrAlgorithms(adrAlgorithms); }); + + DeviceProfileStore.listCodecPlugins((resp: ListDeviceProfileCodecPluginsResponse) => { + const codecPlugins: [string, string][] = []; + for (const a of resp.getResultList()) { + codecPlugins.push([a.getId(), a.getName()]); + } + + setCodecPlugins(codecPlugins); + }); }, [props]); const onTabChange = (activeKey: string) => { @@ -244,6 +257,7 @@ function DeviceProfileForm(props: IProps) { dp.setMacVersion(v.macVersion); dp.setRegParamsRevision(v.regParamsRevision); dp.setAdrAlgorithmId(v.adrAlgorithmId); + dp.setCodecPluginId(v.codecPluginId); dp.setFlushQueueOnActivate(v.flushQueueOnActivate); dp.setUplinkInterval(v.uplinkInterval); dp.setDeviceStatusReqInterval(v.deviceStatusReqInterval); @@ -366,6 +380,7 @@ function DeviceProfileForm(props: IProps) { macVersion: dp.getMacVersion(), regParamsRevision: dp.getRegParamsRevision(), adrAlgorithmId: dp.getAdrAlgorithmId(), + codecPluginId: dp.getCodecPluginId(), payloadCodecRuntime: dp.getPayloadCodecRuntime(), payloadCodecScript: dp.getPayloadCodecScript(), flushQueueOnActivate: dp.getFlushQueueOnActivate(), @@ -412,6 +427,7 @@ function DeviceProfileForm(props: IProps) { }; const adrOptions = adrAlgorithms.map(v => {v[1]}); + const codecPluginOptions = codecPlugins.map(v => {v[1]}); const regionConfigOptions = regionConfigurationsFiltered.map(v => {v[1]}); const regionOptions = regionConfigurations .map(v => v.getRegion()) @@ -748,8 +764,19 @@ function DeviceProfileForm(props: IProps) { None Cayenne LPP JavaScript functions + JavaScript plugin + {payloadCodecRuntime === CodecRuntime.JS_PLUGIN && ( + + + + )} {payloadCodecRuntime === CodecRuntime.JS && ( )}