diff --git a/api/proto/api/fuota.proto b/api/proto/api/fuota.proto index c96e2f8e..ead9b854 100644 --- a/api/proto/api/fuota.proto +++ b/api/proto/api/fuota.proto @@ -112,39 +112,47 @@ message FuotaDeployment { // has a different meaning for Class-B and Class-C groups. uint32 multicast_timeout = 10; - // Unicast attempt count. - // The number of attempts before considering an unicast command - // to be failed. - uint32 unicast_attempt_count = 11; + // Calculate multicast timeout. + // If set to true, ChirpStack will calculate the multicast-timeout. + bool calculate_multicast_timeout = 11; + + // The number of times ChirpStack will retry an unicast command + // before it considers it to be failed. + uint32 unicast_max_retry_count = 12; // Fragmentation size. // This defines the size of each payload fragment. Please refer to the // Regional Parameters specification for the maximum payload sizes // per data-rate and region. - uint32 fragmentation_fragment_size = 12; + uint32 fragmentation_fragment_size = 13; - // Fragmentation redundancy. - // The number represents the additional redundant frames to send. - uint32 fragmentation_redundancy = 13; + // Calculate fragmentation size. + // If set to true, ChirpStack will calculate the fragmentation size. + bool calculate_fragmentation_fragment_size = 14; + + // Fragmentation redundancy percentage. + // The number represents the percentage (0 - 100) of redundant messages + // to send. + uint32 fragmentation_redundancy_percentage = 15; // Fragmentation session index. - uint32 fragmentation_session_index = 14; + uint32 fragmentation_session_index = 16; // Fragmentation matrix. - uint32 fragmentation_matrix = 15; + uint32 fragmentation_matrix = 17; // Block ack delay. - uint32 fragmentation_block_ack_delay = 16; + uint32 fragmentation_block_ack_delay = 18; // Descriptor (4 bytes). - bytes fragmentation_descriptor = 17; + bytes fragmentation_descriptor = 19; // Request fragmentation session status. - RequestFragmentationSessionStatus request_fragmentation_session_status = 18; + RequestFragmentationSessionStatus request_fragmentation_session_status = 20; // Payload. // The FUOTA payload to send. - bytes payload = 19; + bytes payload = 21; } message FuotaDeploymentListItem { @@ -228,6 +236,12 @@ message GetFuotaDeploymentResponse { // Updated at timestamp. google.protobuf.Timestamp updated_at = 3; + + // Started at timestamp. + google.protobuf.Timestamp started_at = 4; + + // Completed at timestamp. + google.protobuf.Timestamp completed_at = 5; } message UpdateFuotaDeploymentRequest { diff --git a/api/rust/proto/chirpstack/api/fuota.proto b/api/rust/proto/chirpstack/api/fuota.proto index c96e2f8e..ead9b854 100644 --- a/api/rust/proto/chirpstack/api/fuota.proto +++ b/api/rust/proto/chirpstack/api/fuota.proto @@ -112,39 +112,47 @@ message FuotaDeployment { // has a different meaning for Class-B and Class-C groups. uint32 multicast_timeout = 10; - // Unicast attempt count. - // The number of attempts before considering an unicast command - // to be failed. - uint32 unicast_attempt_count = 11; + // Calculate multicast timeout. + // If set to true, ChirpStack will calculate the multicast-timeout. + bool calculate_multicast_timeout = 11; + + // The number of times ChirpStack will retry an unicast command + // before it considers it to be failed. + uint32 unicast_max_retry_count = 12; // Fragmentation size. // This defines the size of each payload fragment. Please refer to the // Regional Parameters specification for the maximum payload sizes // per data-rate and region. - uint32 fragmentation_fragment_size = 12; + uint32 fragmentation_fragment_size = 13; - // Fragmentation redundancy. - // The number represents the additional redundant frames to send. - uint32 fragmentation_redundancy = 13; + // Calculate fragmentation size. + // If set to true, ChirpStack will calculate the fragmentation size. + bool calculate_fragmentation_fragment_size = 14; + + // Fragmentation redundancy percentage. + // The number represents the percentage (0 - 100) of redundant messages + // to send. + uint32 fragmentation_redundancy_percentage = 15; // Fragmentation session index. - uint32 fragmentation_session_index = 14; + uint32 fragmentation_session_index = 16; // Fragmentation matrix. - uint32 fragmentation_matrix = 15; + uint32 fragmentation_matrix = 17; // Block ack delay. - uint32 fragmentation_block_ack_delay = 16; + uint32 fragmentation_block_ack_delay = 18; // Descriptor (4 bytes). - bytes fragmentation_descriptor = 17; + bytes fragmentation_descriptor = 19; // Request fragmentation session status. - RequestFragmentationSessionStatus request_fragmentation_session_status = 18; + RequestFragmentationSessionStatus request_fragmentation_session_status = 20; // Payload. // The FUOTA payload to send. - bytes payload = 19; + bytes payload = 21; } message FuotaDeploymentListItem { @@ -228,6 +236,12 @@ message GetFuotaDeploymentResponse { // Updated at timestamp. google.protobuf.Timestamp updated_at = 3; + + // Started at timestamp. + google.protobuf.Timestamp started_at = 4; + + // Completed at timestamp. + google.protobuf.Timestamp completed_at = 5; } message UpdateFuotaDeploymentRequest { diff --git a/chirpstack/migrations_postgres/2025-01-21-093745_add_fuota_support/up.sql b/chirpstack/migrations_postgres/2025-01-21-093745_add_fuota_support/up.sql index 555a60f6..1e093d1f 100644 --- a/chirpstack/migrations_postgres/2025-01-21-093745_add_fuota_support/up.sql +++ b/chirpstack/migrations_postgres/2025-01-21-093745_add_fuota_support/up.sql @@ -13,9 +13,9 @@ create table fuota_deployment ( multicast_class_b_ping_slot_nb_k smallint not null, multicast_frequency bigint not null, multicast_timeout smallint not null, - unicast_attempt_count smallint not null, + unicast_max_retry_count smallint not null, fragmentation_fragment_size smallint not null, - fragmentation_redundancy smallint not null, + fragmentation_redundancy_percentage smallint not null, fragmentation_session_index smallint not null, fragmentation_matrix smallint not null, fragmentation_block_ack_delay smallint not null, @@ -51,7 +51,7 @@ create table fuota_deployment_job ( job varchar(20) not null, created_at timestamp with time zone not null, completed_at timestamp with time zone null, - max_attempt_count smallint not null, + max_retry_count smallint not null, attempt_count smallint not null, scheduler_run_after timestamp with time zone not null, diff --git a/chirpstack/migrations_sqlite/2025-01-27-100007_add_fuota_support/up.sql b/chirpstack/migrations_sqlite/2025-01-27-100007_add_fuota_support/up.sql index ec72c889..0f8218b1 100644 --- a/chirpstack/migrations_sqlite/2025-01-27-100007_add_fuota_support/up.sql +++ b/chirpstack/migrations_sqlite/2025-01-27-100007_add_fuota_support/up.sql @@ -13,9 +13,9 @@ create table fuota_deployment ( multicast_class_b_ping_slot_nb_k smallint not null, multicast_frequency bigint not null, multicast_timeout smallint not null, - unicast_attempt_count smallint not null, + unicast_max_retry_count smallint not null, fragmentation_fragment_size smallint not null, - fragmentation_redundancy smallint not null, + fragmentation_redundancy_percentage smallint not null, fragmentation_session_index smallint not null, fragmentation_matrix smallint not null, fragmentation_block_ack_delay smallint not null, @@ -51,7 +51,7 @@ create table fuota_deployment_job ( job varchar(20) not null, created_at datetime not null, completed_at datetime null, - max_attempt_count smallint not null, + max_retry_count smallint not null, attempt_count smallint not null, scheduler_run_after datetime not null, diff --git a/chirpstack/src/api/fuota.rs b/chirpstack/src/api/fuota.rs index 39f5bde9..9787ccf0 100644 --- a/chirpstack/src/api/fuota.rs +++ b/chirpstack/src/api/fuota.rs @@ -1,5 +1,6 @@ use std::str::FromStr; +use chrono::Utc; use tonic::{Request, Response, Status}; use uuid::Uuid; @@ -45,7 +46,7 @@ impl FuotaService for Fuota { ) .await?; - let dp = fuota::FuotaDeployment { + let mut dp = fuota::FuotaDeployment { name: req_dp.name.clone(), application_id: app_id.into(), device_profile_id: dp_id.into(), @@ -61,9 +62,9 @@ impl FuotaService for Fuota { multicast_class_b_ping_slot_nb_k: req_dp.multicast_class_b_ping_slot_nb_k as i16, multicast_frequency: req_dp.multicast_frequency as i64, multicast_timeout: req_dp.multicast_timeout as i16, - unicast_attempt_count: req_dp.unicast_attempt_count as i16, + unicast_max_retry_count: req_dp.unicast_max_retry_count as i16, fragmentation_fragment_size: req_dp.fragmentation_fragment_size as i16, - fragmentation_redundancy: req_dp.fragmentation_redundancy as i16, + fragmentation_redundancy_percentage: req_dp.fragmentation_redundancy_percentage as i16, fragmentation_session_index: req_dp.fragmentation_session_index as i16, fragmentation_matrix: req_dp.fragmentation_matrix as i16, fragmentation_block_ack_delay: req_dp.fragmentation_block_ack_delay as i16, @@ -74,6 +75,16 @@ impl FuotaService for Fuota { payload: req_dp.payload.clone(), ..Default::default() }; + if req_dp.calculate_fragmentation_fragment_size { + dp.fragmentation_fragment_size = fuota::get_max_fragment_size(&dp) + .await + .map_err(|e| e.status())? as i16; + } + if req_dp.calculate_multicast_timeout { + dp.multicast_timeout = + fuota::get_multicast_timeout(&dp).map_err(|e| e.status())? as i16; + } + let dp = fuota::create_deployment(dp).await.map_err(|e| e.status())?; let mut resp = Response::new(api::CreateFuotaDeploymentResponse { @@ -123,9 +134,9 @@ impl FuotaService for Fuota { multicast_class_b_ping_slot_nb_k: dp.multicast_class_b_ping_slot_nb_k as u32, multicast_frequency: dp.multicast_frequency as u32, multicast_timeout: dp.multicast_timeout as u32, - unicast_attempt_count: dp.unicast_attempt_count as u32, + unicast_max_retry_count: dp.unicast_max_retry_count as u32, fragmentation_fragment_size: dp.fragmentation_fragment_size as u32, - fragmentation_redundancy: dp.fragmentation_redundancy as u32, + fragmentation_redundancy_percentage: dp.fragmentation_redundancy_percentage as u32, fragmentation_session_index: dp.fragmentation_session_index as u32, fragmentation_matrix: dp.fragmentation_matrix as u32, fragmentation_block_ack_delay: dp.fragmentation_block_ack_delay as u32, @@ -135,9 +146,19 @@ impl FuotaService for Fuota { .to_proto() .into(), payload: dp.payload.clone(), + calculate_multicast_timeout: false, + calculate_fragmentation_fragment_size: false, }), created_at: Some(helpers::datetime_to_prost_timestamp(&dp.created_at)), updated_at: Some(helpers::datetime_to_prost_timestamp(&dp.updated_at)), + started_at: dp + .started_at + .as_ref() + .map(helpers::datetime_to_prost_timestamp), + completed_at: dp + .completed_at + .as_ref() + .map(helpers::datetime_to_prost_timestamp), }); resp.metadata_mut() .insert("x-log-fuota_deployment_id", req.id.parse().unwrap()); @@ -167,7 +188,7 @@ impl FuotaService for Fuota { ) .await?; - let _ = fuota::update_deployment(fuota::FuotaDeployment { + let mut dp = fuota::FuotaDeployment { id: id.into(), name: req_dp.name.clone(), application_id: app_id.into(), @@ -184,9 +205,9 @@ impl FuotaService for Fuota { multicast_class_b_ping_slot_nb_k: req_dp.multicast_class_b_ping_slot_nb_k as i16, multicast_frequency: req_dp.multicast_frequency as i64, multicast_timeout: req_dp.multicast_timeout as i16, - unicast_attempt_count: req_dp.unicast_attempt_count as i16, + unicast_max_retry_count: req_dp.unicast_max_retry_count as i16, fragmentation_fragment_size: req_dp.fragmentation_fragment_size as i16, - fragmentation_redundancy: req_dp.fragmentation_redundancy as i16, + fragmentation_redundancy_percentage: req_dp.fragmentation_redundancy_percentage as i16, fragmentation_session_index: req_dp.fragmentation_session_index as i16, fragmentation_matrix: req_dp.fragmentation_matrix as i16, fragmentation_block_ack_delay: req_dp.fragmentation_block_ack_delay as i16, @@ -196,9 +217,18 @@ impl FuotaService for Fuota { .from_proto(), payload: req_dp.payload.clone(), ..Default::default() - }) - .await - .map_err(|e| e.status())?; + }; + if req_dp.calculate_fragmentation_fragment_size { + dp.fragmentation_fragment_size = fuota::get_max_fragment_size(&dp) + .await + .map_err(|e| e.status())? as i16; + } + if req_dp.calculate_multicast_timeout { + dp.multicast_timeout = + fuota::get_multicast_timeout(&dp).map_err(|e| e.status())? as i16; + } + + let _ = fuota::update_deployment(dp).await.map_err(|e| e.status())?; let mut resp = Response::new(()); resp.metadata_mut() @@ -242,12 +272,20 @@ impl FuotaService for Fuota { ) .await?; - let d = fuota::get_deployment(id).await.map_err(|e| e.status())?; + let mut d = fuota::get_deployment(id).await.map_err(|e| e.status())?; + if d.started_at.is_some() { + return Err(Status::failed_precondition( + "FUOTA deployment has already started", + )); + } + + d.started_at = Some(Utc::now()); + let d = fuota::update_deployment(d).await.map_err(|e| e.status())?; fuota::create_job(fuota::FuotaDeploymentJob { fuota_deployment_id: d.id, job: fields::FuotaJob::McGroupSetup, - max_attempt_count: d.unicast_attempt_count, + max_retry_count: d.unicast_max_retry_count, ..Default::default() }) .await diff --git a/chirpstack/src/storage/fuota.rs b/chirpstack/src/storage/fuota.rs index 31b4c36d..49d367fc 100644 --- a/chirpstack/src/storage/fuota.rs +++ b/chirpstack/src/storage/fuota.rs @@ -32,9 +32,9 @@ pub struct FuotaDeployment { pub multicast_class_b_ping_slot_nb_k: i16, pub multicast_frequency: i64, pub multicast_timeout: i16, - pub unicast_attempt_count: i16, + pub unicast_max_retry_count: i16, pub fragmentation_fragment_size: i16, - pub fragmentation_redundancy: i16, + pub fragmentation_redundancy_percentage: i16, pub fragmentation_session_index: i16, pub fragmentation_matrix: i16, pub fragmentation_block_ack_delay: i16, @@ -62,9 +62,9 @@ impl Default for FuotaDeployment { multicast_class_b_ping_slot_nb_k: 0, multicast_frequency: 0, multicast_timeout: 0, - unicast_attempt_count: 0, + unicast_max_retry_count: 0, fragmentation_fragment_size: 0, - fragmentation_redundancy: 0, + fragmentation_redundancy_percentage: 0, fragmentation_session_index: 0, fragmentation_matrix: 0, fragmentation_block_ack_delay: 0, @@ -141,7 +141,7 @@ pub struct FuotaDeploymentJob { pub job: fields::FuotaJob, pub created_at: DateTime, pub completed_at: Option>, - pub max_attempt_count: i16, + pub max_retry_count: i16, pub attempt_count: i16, pub scheduler_run_after: DateTime, } @@ -155,7 +155,7 @@ impl Default for FuotaDeploymentJob { job: fields::FuotaJob::McGroupSetup, created_at: now, completed_at: None, - max_attempt_count: 0, + max_retry_count: 0, attempt_count: 0, scheduler_run_after: now, } @@ -208,9 +208,10 @@ pub async fn update_deployment(d: FuotaDeployment) -> Result Result Result { + let dp = device_profile::get(&d.device_profile_id).await?; + let region_conf = lrwn::region::get(dp.region, false, false); + let max_pl_size = region_conf + .get_max_payload_size(dp.mac_version, dp.reg_params_revision, d.multicast_dr as u8)? + .n + - 3; + + Ok(max_pl_size) +} + +pub fn get_multicast_timeout(d: &FuotaDeployment) -> Result { + let conf = config::get(); + + let fragments = (d.payload.len() as f32 / d.fragmentation_fragment_size as f32).ceil() as usize; + let redundancy = + (fragments as f32 * d.fragmentation_redundancy_percentage as f32 / 100.0).ceil() as usize; + let total_fragments = fragments + redundancy; + + match d.multicast_group_type.as_ref() { + "B" => { + // Calculate number of ping-slots per beacon period. + let nb_ping_slots = 1 << (d.multicast_class_b_ping_slot_nb_k as usize); + + // Calculate number of beacon-periods needed. + // One beacon period is added as the first ping-slot might be in the next beacon-period. + let beacon_periods = + (total_fragments as f32 / nb_ping_slots as f32).ceil() as usize + 1; + + // Calculate the timeout value. In case of Class-B, timeout represents the number + // of beacon periods (beacon periods = 2^timeout). + for i in 0..16 { + // i is 0-15 + if (1 << i) >= beacon_periods { + return Ok(i); + } + } + + Err(anyhow!("Max. number of beacon period exceeded")) + } + "C" => { + // Get the margin between each multicast Class-C downlink. + let mc_class_c_margin_secs = + conf.network.scheduler.multicast_class_c_margin.as_secs() as usize; + + // Multiply by the number of fragments (+1 for additional margin). + let mc_class_c_duration_secs = mc_class_c_margin_secs * (total_fragments + 1 as usize); + + // Calculate the timeout value. In case of Class-B, timeout is defined as seconds, + // where the number of seconds is 2^timeout. + for i in 0..16 { + // i = 0-15 + if (1 << i) >= mc_class_c_duration_secs { + return Ok(i); + } + } + + Err(anyhow!("Max timeout exceeded")) + } + _ => Ok(0), + } +} + #[cfg(test)] mod test { use super::*; @@ -893,7 +957,7 @@ mod test { let mut job = create_job(FuotaDeploymentJob { fuota_deployment_id: d.id, job: fields::FuotaJob::McGroupSetup, - max_attempt_count: 3, + max_retry_count: 3, attempt_count: 1, ..Default::default() }) @@ -915,7 +979,7 @@ mod test { let job2 = create_job(FuotaDeploymentJob { fuota_deployment_id: d.id, job: fields::FuotaJob::FragStatus, - max_attempt_count: 3, + max_retry_count: 3, attempt_count: 1, ..Default::default() }) @@ -937,4 +1001,135 @@ mod test { let jobs = get_schedulable_jobs(10).await.unwrap(); assert_eq!(0, jobs.len()); } + + #[tokio::test] + async fn test_get_max_fragment_size() { + let _guard = test::prepare().await; + + let t = tenant::create(tenant::Tenant { + name: "test-tenant".into(), + ..Default::default() + }) + .await + .unwrap(); + + let app = application::create(application::Application { + name: "test-app".into(), + tenant_id: t.id, + ..Default::default() + }) + .await + .unwrap(); + + let dp = device_profile::create(device_profile::DeviceProfile { + tenant_id: t.id, + name: "test-dp".into(), + ..Default::default() + }) + .await + .unwrap(); + + // create + let d = create_deployment(FuotaDeployment { + application_id: app.id, + device_profile_id: dp.id, + name: "test-fuota-deployment".into(), + multicast_dr: 5, + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(239, get_max_fragment_size(&d).await.unwrap()); + } + + #[tokio::test] + async fn test_get_multicast_timeout() { + let _guard = test::prepare().await; + + struct Test { + name: String, + deployment: FuotaDeployment, + expected_timeout: usize, + expected_error: Option, + } + + let tests = [ + Test { + name: "Class-B - 1 / beacon period - 15 fragments".into(), + deployment: FuotaDeployment { + multicast_group_type: "B".into(), + multicast_class_b_ping_slot_nb_k: 0, + fragmentation_fragment_size: 10, + fragmentation_redundancy_percentage: 50, + payload: vec![0; 100], + ..Default::default() + }, + expected_timeout: 4, + expected_error: None, + }, + Test { + name: "Class-B - 1 / beacon period - 16 fragments".into(), + deployment: FuotaDeployment { + multicast_group_type: "B".into(), + multicast_class_b_ping_slot_nb_k: 0, + fragmentation_fragment_size: 10, + fragmentation_redundancy_percentage: 60, + payload: vec![0; 100], + ..Default::default() + }, + expected_timeout: 5, + expected_error: None, + }, + Test { + name: "Class-B - 16 / beacon period - 16 fragments".into(), + deployment: FuotaDeployment { + multicast_group_type: "B".into(), + multicast_class_b_ping_slot_nb_k: 4, + fragmentation_fragment_size: 10, + fragmentation_redundancy_percentage: 60, + payload: vec![0; 100], + ..Default::default() + }, + expected_timeout: 1, + expected_error: None, + }, + Test { + name: "Class-B - 16 / beacon period - 17 fragments".into(), + deployment: FuotaDeployment { + multicast_group_type: "B".into(), + multicast_class_b_ping_slot_nb_k: 4, + fragmentation_fragment_size: 10, + fragmentation_redundancy_percentage: 70, + payload: vec![0; 100], + ..Default::default() + }, + expected_timeout: 2, + expected_error: None, + }, + Test { + name: "Class-C - 1 fragment".into(), + deployment: FuotaDeployment { + multicast_group_type: "C".into(), + fragmentation_fragment_size: 10, + payload: vec![0; 10], + ..Default::default() + }, + expected_timeout: 3, + expected_error: None, + }, + ]; + + for t in &tests { + println!("> {}", t.name); + let res = get_multicast_timeout(&t.deployment); + if let Some(err_str) = &t.expected_error { + assert!(res.is_err()); + assert_eq!(err_str, &res.err().unwrap().to_string()); + } else { + assert!(res.is_ok()); + assert_eq!(t.expected_timeout, res.unwrap()); + } + } + } } diff --git a/chirpstack/src/storage/schema_postgres.rs b/chirpstack/src/storage/schema_postgres.rs index a0c7e85f..78941669 100644 --- a/chirpstack/src/storage/schema_postgres.rs +++ b/chirpstack/src/storage/schema_postgres.rs @@ -200,9 +200,9 @@ diesel::table! { multicast_class_b_ping_slot_nb_k -> Int2, multicast_frequency -> Int8, multicast_timeout -> Int2, - unicast_attempt_count -> Int2, + unicast_max_retry_count -> Int2, fragmentation_fragment_size -> Int2, - fragmentation_redundancy -> Int2, + fragmentation_redundancy_percentage -> Int2, fragmentation_session_index -> Int2, fragmentation_matrix -> Int2, fragmentation_block_ack_delay -> Int2, @@ -241,7 +241,7 @@ diesel::table! { job -> Varchar, created_at -> Timestamptz, completed_at -> Nullable, - max_attempt_count -> Int2, + max_retry_count -> Int2, attempt_count -> Int2, scheduler_run_after -> Timestamptz, } diff --git a/chirpstack/src/storage/schema_sqlite.rs b/chirpstack/src/storage/schema_sqlite.rs index 8e16f068..b2ca2150 100644 --- a/chirpstack/src/storage/schema_sqlite.rs +++ b/chirpstack/src/storage/schema_sqlite.rs @@ -177,9 +177,9 @@ diesel::table! { multicast_class_b_ping_slot_nb_k -> SmallInt, multicast_frequency -> BigInt, multicast_timeout -> SmallInt, - unicast_attempt_count -> SmallInt, + unicast_max_retry_count -> SmallInt, fragmentation_fragment_size -> SmallInt, - fragmentation_redundancy -> SmallInt, + fragmentation_redundancy_percentage -> SmallInt, fragmentation_session_index -> SmallInt, fragmentation_matrix -> SmallInt, fragmentation_block_ack_delay -> SmallInt, @@ -216,7 +216,7 @@ diesel::table! { job -> Text, created_at -> TimestamptzSqlite, completed_at -> Nullable, - max_attempt_count -> SmallInt, + max_retry_count -> SmallInt, attempt_count -> SmallInt, scheduler_run_after -> TimestamptzSqlite, }