diff --git a/api/proto/api/fuota.proto b/api/proto/api/fuota.proto index ead9b854..4cfd546e 100644 --- a/api/proto/api/fuota.proto +++ b/api/proto/api/fuota.proto @@ -57,6 +57,9 @@ service FuotaService { // Remove the given Gateway IDs from the FUOTA deployment. rpc RemoveGateways(RemoveGatewaysFromFuotaDeploymentRequest) returns (google.protobuf.Empty) {} // GetLogs returns the logs for the FUOTA deployment. + + // List jobs for the given FUOTA deployment. + rpc ListJobs(ListFuotaDeploymentJobsRequest) returns (ListFuotaDeploymentJobsResponse) {} } enum RequestFragmentationSessionStatus { @@ -185,8 +188,8 @@ message FuotaDeploymentDeviceListItem { // Created at timestamp. google.protobuf.Timestamp created_at = 3; - // Updated at timestamp. - google.protobuf.Timestamp updated_at = 4; + // Completed at timestamp. + google.protobuf.Timestamp completed_at = 4; // McGroupSetup completed at timestamp. google.protobuf.Timestamp mc_group_setup_completed_at = 5; @@ -199,6 +202,9 @@ message FuotaDeploymentDeviceListItem { // FragStatus completed at timestamp. google.protobuf.Timestamp frag_status_completed_at = 8; + + // Error message. + string error_msg = 9; } message FuotaDeploymentGatewayListItem { @@ -354,3 +360,36 @@ message ListFuotaDeploymentGatewaysResponse { // Result-set. repeated FuotaDeploymentGatewayListItem result = 2; } + +message ListFuotaDeploymentJobsRequest { + // FUOTA Deployment ID. + string fuota_deployment_id = 1; +} + +message ListFuotaDeploymentJobsResponse { + // Jobs. + repeated FuotaDeploymentJob jobs = 1; +} + +message FuotaDeploymentJob { + // Job identifier. + string job = 1; + + // Created at. + google.protobuf.Timestamp created_at = 2; + + // Completed at. + google.protobuf.Timestamp completed_at = 3; + + // Max. retry count. + uint32 max_retry_count = 4; + + // Attempt count. + uint32 attempt_count = 5; + + // Scheduler run after. + google.protobuf.Timestamp scheduler_run_after = 6; + + // Error message. + string error_msg = 7; +} diff --git a/api/rust/proto/chirpstack/api/fuota.proto b/api/rust/proto/chirpstack/api/fuota.proto index ead9b854..4cfd546e 100644 --- a/api/rust/proto/chirpstack/api/fuota.proto +++ b/api/rust/proto/chirpstack/api/fuota.proto @@ -57,6 +57,9 @@ service FuotaService { // Remove the given Gateway IDs from the FUOTA deployment. rpc RemoveGateways(RemoveGatewaysFromFuotaDeploymentRequest) returns (google.protobuf.Empty) {} // GetLogs returns the logs for the FUOTA deployment. + + // List jobs for the given FUOTA deployment. + rpc ListJobs(ListFuotaDeploymentJobsRequest) returns (ListFuotaDeploymentJobsResponse) {} } enum RequestFragmentationSessionStatus { @@ -185,8 +188,8 @@ message FuotaDeploymentDeviceListItem { // Created at timestamp. google.protobuf.Timestamp created_at = 3; - // Updated at timestamp. - google.protobuf.Timestamp updated_at = 4; + // Completed at timestamp. + google.protobuf.Timestamp completed_at = 4; // McGroupSetup completed at timestamp. google.protobuf.Timestamp mc_group_setup_completed_at = 5; @@ -199,6 +202,9 @@ message FuotaDeploymentDeviceListItem { // FragStatus completed at timestamp. google.protobuf.Timestamp frag_status_completed_at = 8; + + // Error message. + string error_msg = 9; } message FuotaDeploymentGatewayListItem { @@ -354,3 +360,36 @@ message ListFuotaDeploymentGatewaysResponse { // Result-set. repeated FuotaDeploymentGatewayListItem result = 2; } + +message ListFuotaDeploymentJobsRequest { + // FUOTA Deployment ID. + string fuota_deployment_id = 1; +} + +message ListFuotaDeploymentJobsResponse { + // Jobs. + repeated FuotaDeploymentJob jobs = 1; +} + +message FuotaDeploymentJob { + // Job identifier. + string job = 1; + + // Created at. + google.protobuf.Timestamp created_at = 2; + + // Completed at. + google.protobuf.Timestamp completed_at = 3; + + // Max. retry count. + uint32 max_retry_count = 4; + + // Attempt count. + uint32 attempt_count = 5; + + // Scheduler run after. + google.protobuf.Timestamp scheduler_run_after = 6; + + // Error message. + string error_msg = 7; +} 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 b84d85fd..7e9164ea 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 @@ -30,12 +30,12 @@ create table fuota_deployment_device ( fuota_deployment_id uuid not null references fuota_deployment on delete cascade, dev_eui bytea not null references device on delete cascade, created_at timestamp with time zone not null, - updated_at timestamp with time zone not null, + completed_at timestamp with time zone null, mc_group_setup_completed_at timestamp with time zone null, mc_session_completed_at timestamp with time zone null, frag_session_setup_completed_at timestamp with time zone null, frag_status_completed_at timestamp with time zone null, - return_msg text not null, + error_msg text not null, primary key (fuota_deployment_id, dev_eui) ); @@ -56,7 +56,7 @@ create table fuota_deployment_job ( max_retry_count smallint not null, attempt_count smallint not null, scheduler_run_after timestamp with time zone not null, - return_msg text not null, + error_msg text not null, primary key (fuota_deployment_id, job) ); 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 2abc38b6..0b1ae080 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 @@ -30,12 +30,12 @@ create table fuota_deployment_device ( fuota_deployment_id text not null references fuota_deployment on delete cascade, dev_eui blob not null references device on delete cascade, created_at datetime not null, - updated_at datetime not null, + completed_at datetime null, mc_group_setup_completed_at datetime null, mc_session_completed_at datetime null, frag_session_setup_completed_at datetime null, frag_status_completed_at datetime null, - return_msg text not null, + error_msg text not null, primary key (fuota_deployment_id, dev_eui) ); @@ -56,7 +56,7 @@ create table fuota_deployment_job ( max_retry_count smallint not null, attempt_count smallint not null, scheduler_run_after datetime not null, - return_msg text not null, + error_msg text not null, primary key (fuota_deployment_id, job) ); diff --git a/chirpstack/src/api/device_profile.rs b/chirpstack/src/api/device_profile.rs index 3cd1718c..220783c2 100644 --- a/chirpstack/src/api/device_profile.rs +++ b/chirpstack/src/api/device_profile.rs @@ -599,7 +599,14 @@ pub mod test { mac_version: common::MacVersion::Lorawan103.into(), reg_params_revision: common::RegParamsRevision::A.into(), adr_algorithm_id: "default".into(), - app_layer_params: Some(api::AppLayerParams::default()), + app_layer_params: Some(api::AppLayerParams { + ts003_version: api::Ts003Version::Ts003NotImplemented.into(), + ts003_f_port: 202, + ts004_version: api::Ts004Version::Ts004NotImplemented.into(), + ts004_f_port: 201, + ts005_version: api::Ts005Version::Ts005NotImplemented.into(), + ts005_f_port: 200, + }), ..Default::default() }), get_resp.get_ref().device_profile diff --git a/chirpstack/src/api/fuota.rs b/chirpstack/src/api/fuota.rs index 461f1879..7925266c 100644 --- a/chirpstack/src/api/fuota.rs +++ b/chirpstack/src/api/fuota.rs @@ -192,6 +192,13 @@ impl FuotaService for Fuota { ) .await?; + let 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", + )); + } + let mut dp = fuota::FuotaDeployment { id: id.into(), name: req_dp.name.clone(), @@ -361,6 +368,13 @@ impl FuotaService for Fuota { ) .await?; + let d = fuota::get_deployment(dp_id).await.map_err(|e| e.status())?; + if d.started_at.is_some() { + return Err(Status::failed_precondition( + "FUOTA deployment has already started", + )); + } + let mut dev_euis = Vec::with_capacity(req.dev_euis.len()); for dev_eui in &req.dev_euis { dev_euis.push(EUI64::from_str(dev_eui).map_err(|e| e.status())?); @@ -438,7 +452,10 @@ impl FuotaService for Fuota { fuota_deployment_id: d.fuota_deployment_id.to_string(), dev_eui: d.dev_eui.to_string(), created_at: Some(helpers::datetime_to_prost_timestamp(&d.created_at)), - updated_at: Some(helpers::datetime_to_prost_timestamp(&d.updated_at)), + completed_at: d + .completed_at + .as_ref() + .map(|ts| helpers::datetime_to_prost_timestamp(ts)), mc_group_setup_completed_at: d .mc_group_setup_completed_at .as_ref() @@ -455,6 +472,7 @@ impl FuotaService for Fuota { .frag_status_completed_at .as_ref() .map(|ts| helpers::datetime_to_prost_timestamp(ts)), + error_msg: d.error_msg.clone(), }) .collect(), }); @@ -480,6 +498,13 @@ impl FuotaService for Fuota { ) .await?; + let d = fuota::get_deployment(dp_id).await.map_err(|e| e.status())?; + if d.started_at.is_some() { + return Err(Status::failed_precondition( + "FUOTA deployment has already started", + )); + } + let mut gateway_ids = Vec::with_capacity(req.gateway_ids.len()); for gateway_id in &req.gateway_ids { gateway_ids.push(EUI64::from_str(gateway_id).map_err(|e| e.status())?); @@ -566,6 +591,48 @@ impl FuotaService for Fuota { ); Ok(resp) } + + async fn list_jobs( + &self, + request: Request, + ) -> Result, Status> { + let req = request.get_ref(); + let dp_id = Uuid::from_str(&req.fuota_deployment_id).map_err(|e| e.status())?; + + self.validator + .validate( + request.extensions(), + validator::ValidateFuotaDeploymentAccess::new(validator::Flag::Read, dp_id), + ) + .await?; + + let jobs = fuota::list_jobs(dp_id).await.map_err(|e| e.status())?; + + let mut resp = Response::new(api::ListFuotaDeploymentJobsResponse { + jobs: jobs + .iter() + .map(|j| api::FuotaDeploymentJob { + job: j.job.to_string(), + created_at: Some(helpers::datetime_to_prost_timestamp(&j.created_at)), + completed_at: j + .completed_at + .as_ref() + .map(|ts| helpers::datetime_to_prost_timestamp(ts)), + max_retry_count: j.max_retry_count as u32, + attempt_count: j.attempt_count as u32, + scheduler_run_after: Some(helpers::datetime_to_prost_timestamp( + &j.scheduler_run_after, + )), + error_msg: j.error_msg.clone(), + }) + .collect(), + }); + resp.metadata_mut().insert( + "x-log-fuota_deployment_id", + req.fuota_deployment_id.parse().unwrap(), + ); + Ok(resp) + } } #[cfg(test)] @@ -723,21 +790,6 @@ mod test { assert_eq!(1, list_resp.result.len()); assert_eq!(create_resp.id, list_resp.result[0].id); - // start deployment - let start_req = get_request( - &u.id, - api::StartFuotaDeploymentRequest { - id: create_resp.id.clone(), - }, - ); - service.start_deployment(start_req).await.unwrap(); - let jobs = fuota::list_jobs(Uuid::from_str(&create_resp.id).unwrap()) - .await - .unwrap(); - assert_eq!(1, jobs.len()); - assert_eq!(create_resp.id, jobs[0].fuota_deployment_id.to_string()); - assert_eq!(fields::FuotaJob::CreateMcGroup, jobs[0].job); - // add device let add_dev_req = get_request( &u.id, @@ -835,6 +887,21 @@ mod test { assert_eq!(0, list_gws_resp.total_count); assert_eq!(0, list_gws_resp.result.len()); + // start deployment + let start_req = get_request( + &u.id, + api::StartFuotaDeploymentRequest { + id: create_resp.id.clone(), + }, + ); + service.start_deployment(start_req).await.unwrap(); + let jobs = fuota::list_jobs(Uuid::from_str(&create_resp.id).unwrap()) + .await + .unwrap(); + assert_eq!(1, jobs.len()); + assert_eq!(create_resp.id, jobs[0].fuota_deployment_id.to_string()); + assert_eq!(fields::FuotaJob::CreateMcGroup, jobs[0].job); + // delete deployment let delete_req = get_request( &u.id, diff --git a/chirpstack/src/applayer/clocksync.rs b/chirpstack/src/applayer/clocksync.rs index 9bdbd3d3..cc400a80 100644 --- a/chirpstack/src/applayer/clocksync.rs +++ b/chirpstack/src/applayer/clocksync.rs @@ -201,7 +201,7 @@ mod test { name: "test-dp".into(), tenant_id: t.id, app_layer_params: fields::AppLayerParams { - ts003_version: Some(fields::Ts003Version::V100), + ts003_version: Some(Ts003Version::V100), ..Default::default() }, ..Default::default() diff --git a/chirpstack/src/applayer/fragmentation.rs b/chirpstack/src/applayer/fragmentation.rs new file mode 100644 index 00000000..92af2263 --- /dev/null +++ b/chirpstack/src/applayer/fragmentation.rs @@ -0,0 +1,96 @@ +use anyhow::Result; +use chrono::Utc; +use tracing::{info, warn}; + +use crate::storage::fields::device_profile::Ts004Version; +use crate::storage::{device, device_profile, fuota}; +use lrwn::applayer::fragmentation; + +pub async fn handle_uplink( + dev: &device::Device, + dp: &device_profile::DeviceProfile, + data: &[u8], +) -> Result<()> { + let version = dp + .app_layer_params + .ts004_version + .ok_or_else(|| anyhow!("Device does not support TS004"))?; + + match version { + Ts004Version::V100 => handle_uplink_v100(dev, data).await, + } +} + +async fn handle_uplink_v100(dev: &device::Device, data: &[u8]) -> Result<()> { + let pl = fragmentation::v1::Payload::from_slice(true, data)?; + + match pl { + fragmentation::v1::Payload::FragSessionSetupAns(pl) => { + handle_v1_frag_session_setup_ans(dev, pl).await? + } + fragmentation::v1::Payload::FragSessionStatusAns(pl) => { + handle_v1_frag_session_status_ans(dev, pl).await? + } + _ => {} + } + + Ok(()) +} + +async fn handle_v1_frag_session_setup_ans( + dev: &device::Device, + pl: fragmentation::v1::FragSessionSetupAnsPayload, +) -> Result<()> { + info!("Handling FragSessionSetupAns"); + + let mut fuota_dev = fuota::get_latest_device_by_dev_eui(dev.dev_eui).await?; + + if pl.encoding_unsupported + | pl.not_enough_memory + | pl.frag_session_index_not_supported + | pl.wrong_descriptor + { + warn!( + frag_index = pl.frag_index, + encoding_unsupported = pl.encoding_unsupported, + not_enough_memory = pl.not_enough_memory, + frag_session_index_not_supported = pl.frag_session_index_not_supported, + wrong_descriptor = pl.wrong_descriptor, + "FragSessionAns contains errors" + ); + fuota_dev.error_msg = format!("Error: FragSessionAns response encoding_unsupported={}, not_enough_memory={}, frag_session_index_not_supported={}, wrong_descriptor={}", pl.encoding_unsupported, pl.not_enough_memory, pl.frag_session_index_not_supported, pl.wrong_descriptor); + } else { + fuota_dev.frag_session_setup_completed_at = Some(Utc::now()); + } + + let _ = fuota::update_device(fuota_dev).await?; + + Ok(()) +} + +async fn handle_v1_frag_session_status_ans( + dev: &device::Device, + pl: fragmentation::v1::FragSessionStatusAnsPayload, +) -> Result<()> { + info!("Handling FragSessionStatusAnsPayload"); + + let mut fuota_dev = fuota::get_latest_device_by_dev_eui(dev.dev_eui).await?; + + if pl.missing_frag != 0 || pl.status.not_enough_matrix_memory { + warn!( + frag_index = pl.received_and_index.frag_index, + nb_frag_received = pl.received_and_index.nb_frag_received, + missing_frag = pl.missing_frag, + not_enough_matrix_memory = pl.status.not_enough_matrix_memory, + "FragSessionStatusAns contains errors" + ); + + fuota_dev.error_msg = format!("Error: FragSessionStatusAns response nb_frag_received={}, missing_frag={}, not_enough_matrix_memory={}", pl.received_and_index.nb_frag_received, pl.missing_frag, pl.status.not_enough_matrix_memory); + } else { + fuota_dev.frag_status_completed_at = Some(Utc::now()); + } + + let _ = fuota::update_device(fuota_dev).await?; + + Ok(()) +} diff --git a/chirpstack/src/applayer/fuota/flow.rs b/chirpstack/src/applayer/fuota/flow.rs new file mode 100644 index 00000000..8a18fd16 --- /dev/null +++ b/chirpstack/src/applayer/fuota/flow.rs @@ -0,0 +1,548 @@ +use std::time::Duration; + +use anyhow::Result; +use chrono::{DateTime, TimeDelta, Utc}; +use tracing::info; + +use lrwn::applayer::{fragmentation, multicastsetup}; +use lrwn::region::MacVersion; + +use crate::config; +use crate::downlink; +use crate::gpstime::ToGpsTime; +use crate::storage::fields::{FuotaJob, RequestFragmentationSessionStatus}; +use crate::storage::{device_keys, device_profile, device_queue, fuota, multicast}; + +pub struct Flow { + scheduler_interval: Duration, + job: fuota::FuotaDeploymentJob, + fuota_deployment: fuota::FuotaDeployment, + device_profile: device_profile::DeviceProfile, +} + +impl Flow { + pub async fn handle_job(job: fuota::FuotaDeploymentJob) -> Result<()> { + let conf = config::get(); + + let fuota_deployment = fuota::get_deployment(job.fuota_deployment_id.into()).await?; + let device_profile = + device_profile::get(&fuota_deployment.device_profile_id.into()).await?; + + let mut flow = Flow { + job, + fuota_deployment, + device_profile, + scheduler_interval: conf.network.scheduler.interval, + }; + flow.dispatch().await + } + + async fn dispatch(&mut self) -> Result<()> { + let resp = match self.job.job { + FuotaJob::CreateMcGroup => self.create_mc_group().await, + FuotaJob::AddDevsToMcGroup => self.add_devices_to_multicast_group().await, + FuotaJob::AddGwsToMcGroup => self.add_gateways_to_multicast_group().await, + FuotaJob::McGroupSetup => self.multicast_group_setup().await, + FuotaJob::FragSessionSetup => self.fragmentation_session_setup().await, + FuotaJob::McSession => self.multicast_session_setup().await, + FuotaJob::Enqueue => self.enqueue().await, + FuotaJob::FragStatus => self.fragmentation_status().await, + FuotaJob::Complete => self.complete().await, + }; + + match resp { + Ok(Some((next_job, scheduler_run_after))) => { + if self.job.job == next_job { + // Re-run the same job in the future. + let mut job = self.job.clone(); + job.scheduler_run_after = scheduler_run_after; + let _ = fuota::update_job(job).await?; + } else { + // Update the current job (to increment the attempt count). + let job = self.job.clone(); + let _ = fuota::update_job(job).await?; + + // Create the next job (which automatically sets the current job to completed). + let _ = fuota::create_job(fuota::FuotaDeploymentJob { + fuota_deployment_id: self.job.fuota_deployment_id, + job: next_job, + max_retry_count: match next_job { + FuotaJob::McGroupSetup + | FuotaJob::FragSessionSetup + | FuotaJob::McSession => self.fuota_deployment.unicast_max_retry_count, + _ => 0, + }, + scheduler_run_after, + ..Default::default() + }) + .await?; + } + } + Ok(None) => { + // No further jobs to execute, set the current job to completed. + let mut job = self.job.clone(); + job.completed_at = Some(Utc::now()); + let _ = fuota::update_job(job).await?; + } + Err(e) => { + // Re-run the same job in the future. + let mut job = self.job.clone(); + job.scheduler_run_after = Utc::now() + self.scheduler_interval; + job.error_msg = format!("Error: {}", e); + let _ = fuota::update_job(job).await?; + return Err(e); + } + } + + Ok(()) + } + + async fn create_mc_group(&mut self) -> Result)>> { + // If this job fails, then there is no need to execute the others. + if self.job.attempt_count > self.job.max_retry_count { + return Ok(None); + } + + info!("Creating multicast-group for FUOTA deployment"); + self.job.attempt_count += 1; + + // Get McAppSKey + McNwkSKey. + let mc_app_s_key = multicastsetup::v1::get_mc_app_s_key( + self.fuota_deployment.multicast_key, + self.fuota_deployment.multicast_addr, + )?; + let mc_nwk_s_key = multicastsetup::v1::get_mc_net_s_key( + self.fuota_deployment.multicast_key, + self.fuota_deployment.multicast_addr, + )?; + + let _ = multicast::create(multicast::MulticastGroup { + id: self.fuota_deployment.id, + application_id: self.fuota_deployment.application_id, + name: format!("fuota-{}", self.fuota_deployment.id), + region: self.device_profile.region, + mc_addr: self.fuota_deployment.multicast_addr, + mc_nwk_s_key, + mc_app_s_key, + f_cnt: 0, + group_type: self.fuota_deployment.multicast_group_type.clone(), + frequency: self.fuota_deployment.multicast_frequency, + dr: self.fuota_deployment.multicast_dr, + class_b_ping_slot_nb_k: self.fuota_deployment.multicast_class_b_ping_slot_nb_k, + class_c_scheduling_type: self.fuota_deployment.multicast_class_c_scheduling_type, + ..Default::default() + }) + .await?; + + Ok(Some((FuotaJob::AddDevsToMcGroup, Utc::now()))) + } + + async fn add_devices_to_multicast_group( + &mut self, + ) -> Result)>> { + // If this job fails, then there is no need to execute the others. + if self.job.attempt_count > self.job.max_retry_count { + return Ok(None); + } + + info!("Adding devices to multicast-group"); + self.job.attempt_count += 1; + + let fuota_devices = fuota::get_devices(self.job.fuota_deployment_id.into(), -1, 0).await?; + for fuota_d in fuota_devices { + multicast::add_device(&fuota_d.fuota_deployment_id, &fuota_d.dev_eui).await?; + } + + Ok(Some((FuotaJob::AddGwsToMcGroup, Utc::now()))) + } + + async fn add_gateways_to_multicast_group( + &mut self, + ) -> Result)>> { + // If this job fails, then there is no need to execute the others. + if self.job.attempt_count > self.job.max_retry_count { + return Ok(None); + } + + info!("Adding gateways to multicast-group (if any)"); + self.job.attempt_count += 1; + + let fuota_gws = fuota::get_gateways(self.job.fuota_deployment_id.into(), -1, 0).await?; + for fuota_gw in fuota_gws { + multicast::add_gateway(&fuota_gw.fuota_deployment_id, &fuota_gw.gateway_id).await?; + } + + Ok(Some((FuotaJob::McGroupSetup, Utc::now()))) + } + + async fn multicast_group_setup(&mut self) -> Result)>> { + // Proceed with next step after reaching the max attempts. + if self.job.attempt_count > self.job.max_retry_count { + return Ok(Some((FuotaJob::FragSessionSetup, Utc::now()))); + } + + info!("Sending McGroupSetupReq commands to devices"); + self.job.attempt_count += 1; + + let fuota_devices = fuota::get_devices(self.job.fuota_deployment_id.into(), -1, 0).await?; + + // Filter on devices that have not completed the McGroupSetup. + let fuota_devices: Vec = fuota_devices + .into_iter() + .filter(|d| d.mc_group_setup_completed_at.is_none()) + .collect(); + + for fuota_dev in &fuota_devices { + let dev_keys = device_keys::get(&fuota_dev.dev_eui).await?; + let mc_root_key = match self.device_profile.mac_version { + MacVersion::LORAWAN_1_0_0 + | MacVersion::LORAWAN_1_0_1 + | MacVersion::LORAWAN_1_0_2 + | MacVersion::LORAWAN_1_0_3 + | MacVersion::LORAWAN_1_0_4 => { + multicastsetup::v1::get_mc_root_key_for_gen_app_key(dev_keys.gen_app_key)? + } + MacVersion::LORAWAN_1_1_0 | MacVersion::Latest => { + multicastsetup::v1::get_mc_root_key_for_app_key(dev_keys.app_key)? + } + }; + let mc_ke_key = multicastsetup::v1::get_mc_ke_key(mc_root_key)?; + let mc_key_encrypted = + multicastsetup::v1::encrypt_mc_key(mc_ke_key, self.fuota_deployment.multicast_key); + + let pl = multicastsetup::v1::Payload::McGroupSetupReq( + multicastsetup::v1::McGroupSetupReqPayload { + mc_group_id_header: multicastsetup::v1::McGroupSetupReqPayloadMcGroupIdHeader { + mc_group_id: 0, + }, + mc_addr: self.fuota_deployment.multicast_addr, + mc_key_encrypted, + min_mc_f_count: 0, + max_mc_f_count: u32::MAX, + }, + ); + + device_queue::enqueue_item(device_queue::DeviceQueueItem { + dev_eui: fuota_dev.dev_eui, + f_port: self.device_profile.app_layer_params.ts005_f_port.into(), + data: pl.to_vec()?, + ..Default::default() + }) + .await?; + } + + if !fuota_devices.is_empty() { + // There are devices pending setup, we need to re-run this job. + let scheduler_run_after = + Utc::now() + TimeDelta::seconds(self.device_profile.uplink_interval as i64); + Ok(Some((FuotaJob::McGroupSetup, scheduler_run_after))) + } else { + // All devices have completed the setup, move on to next job. + Ok(Some((FuotaJob::FragSessionSetup, Utc::now()))) + } + } + + async fn fragmentation_session_setup(&mut self) -> Result)>> { + // Proceed with next step after reaching the max attempts. + if self.job.attempt_count > self.job.max_retry_count { + return Ok(Some((FuotaJob::McSession, Utc::now()))); + } + + info!("Set timeout error to devices that did not respond to McGroupSetupReq"); + fuota::set_device_timeout_error(self.fuota_deployment.id.into(), true, false, false, false) + .await?; + + info!("Sending FragSessionSetupReq commands to devices"); + self.job.attempt_count += 1; + + let fragment_size = self.fuota_deployment.fragmentation_fragment_size as usize; + let fragments = + (self.fuota_deployment.payload.len() as f32 / fragment_size as f32).ceil() as usize; + let padding = + (fragment_size - (self.fuota_deployment.payload.len() % fragment_size)) % fragment_size; + + let fuota_devices = fuota::get_devices(self.job.fuota_deployment_id.into(), -1, 0).await?; + + // Filter on devices that have completed the previous step, but not yet the FragSessionSetup. + let fuota_devices: Vec = fuota_devices + .into_iter() + .filter(|d| { + d.mc_group_setup_completed_at.is_some() + && d.frag_session_setup_completed_at.is_none() + }) + .collect(); + + for fuota_dev in &fuota_devices { + let pl = fragmentation::v1::Payload::FragSessionSetupReq( + fragmentation::v1::FragSessionSetupReqPayload { + frag_session: fragmentation::v1::FragSessionSetuReqPayloadFragSession { + mc_group_bit_mask: [true, false, false, false], + frag_index: 0, + }, + nb_frag: fragments as u16, + frag_size: fragment_size as u8, + padding: padding as u8, + control: fragmentation::v1::FragSessionSetuReqPayloadControl { + block_ack_delay: 0, + fragmentation_matrix: 0, + }, + descriptor: [0, 0, 0, 0], + }, + ); + + device_queue::enqueue_item(device_queue::DeviceQueueItem { + dev_eui: fuota_dev.dev_eui, + f_port: self.device_profile.app_layer_params.ts004_f_port.into(), + data: pl.to_vec()?, + ..Default::default() + }) + .await?; + } + + if !fuota_devices.is_empty() { + // There are devices pending setup, we need to re-run this job. + let scheduler_run_after = + Utc::now() + TimeDelta::seconds(self.device_profile.uplink_interval as i64); + Ok(Some((FuotaJob::FragSessionSetup, scheduler_run_after))) + } else { + // All devices have completed the setup, move on to next job. + Ok(Some((FuotaJob::McSession, Utc::now()))) + } + } + + async fn multicast_session_setup(&mut self) -> Result)>> { + // Proceed with next step after reaching the max attempts. + if self.job.attempt_count > self.job.max_retry_count { + return Ok(Some((FuotaJob::Enqueue, Utc::now()))); + } + + info!("Set timeout error to devices that did not respond to FragSessionSetupReq"); + fuota::set_device_timeout_error(self.fuota_deployment.id.into(), false, false, true, false) + .await?; + + info!("Sending McClassB/McClassCSessionReq commands to devices"); + self.job.attempt_count += 1; + + let fuota_devices = fuota::get_devices(self.job.fuota_deployment_id.into(), -1, 0).await?; + + // Filter on devices that have completed the previous step, but not yet the McSession. + let fuota_devices: Vec = fuota_devices + .into_iter() + .filter(|d| { + d.frag_session_setup_completed_at.is_some() && d.mc_session_completed_at.is_none() + }) + .collect(); + + for fuota_dev in &fuota_devices { + // We want to start the session (retry_count + 1) x the uplink_interval. + // Note that retry_count=0 means only one attempt. + let session_start = (Utc::now() + + TimeDelta::seconds( + (self.job.max_retry_count as i64 + 1) + * self.device_profile.uplink_interval as i64, + )) + .to_gps_time() + .num_seconds() + % (1 << 32); + + let pl = match self.fuota_deployment.multicast_group_type.as_ref() { + "B" => multicastsetup::v1::Payload::McClassBSessionReq( + multicastsetup::v1::McClassBSessionReqPayload { + mc_group_id_header: + multicastsetup::v1::McClassBSessionReqPayloadMcGroupIdHeader { + mc_group_id: 0, + }, + session_time: (session_start - (session_start % 128)) as u32, + time_out_periodicity: + multicastsetup::v1::McClassBSessionReqPayloadTimeOutPeriodicity { + time_out: self.fuota_deployment.multicast_timeout as u8, + periodicity: self.fuota_deployment.multicast_class_b_ping_slot_nb_k + as u8, + }, + dl_frequ: self.fuota_deployment.multicast_frequency as u32, + dr: self.fuota_deployment.multicast_dr as u8, + }, + ), + "C" => multicastsetup::v1::Payload::McClassCSessionReq( + multicastsetup::v1::McClassCSessionReqPayload { + mc_group_id_header: + multicastsetup::v1::McClassCSessionReqPayloadMcGroupIdHeader { + mc_group_id: 0, + }, + session_time: session_start as u32, + session_time_out: + multicastsetup::v1::McClassCSessionReqPayloadSessionTimeOut { + time_out: self.fuota_deployment.multicast_timeout as u8, + }, + dl_frequ: self.fuota_deployment.multicast_frequency as u32, + dr: self.fuota_deployment.multicast_dr as u8, + }, + ), + _ => { + return Err(anyhow!( + "Unsupported group-type: {}", + self.fuota_deployment.multicast_group_type + )) + } + }; + + device_queue::enqueue_item(device_queue::DeviceQueueItem { + dev_eui: fuota_dev.dev_eui, + f_port: self.device_profile.app_layer_params.ts005_f_port.into(), + data: pl.to_vec()?, + ..Default::default() + }) + .await?; + } + + // In this case we need to exactly try the max. attempts, because this is what the + // session-start time calculation is based on. If we continue with enqueueing too + // early, the multicast-session hasn't started yet. + let scheduler_run_after = + Utc::now() + TimeDelta::seconds(self.device_profile.uplink_interval as i64); + Ok(Some((FuotaJob::McSession, scheduler_run_after))) + } + + async fn enqueue(&mut self) -> Result)>> { + // Proceed with next step after reaching the max attempts. + if self.job.attempt_count > self.job.max_retry_count { + return Ok(Some((FuotaJob::FragStatus, Utc::now()))); + } + + info!("Set timeout error to devices that did not respond to McSessionReq"); + fuota::set_device_timeout_error(self.fuota_deployment.id.into(), false, true, false, false) + .await?; + + info!("Enqueueing fragmented payload to multicast group"); + self.job.attempt_count += 1; + + let payload_length = self.fuota_deployment.payload.len(); + let fragment_size = self.fuota_deployment.fragmentation_fragment_size as usize; + let padding = (fragment_size - (payload_length % fragment_size)) % fragment_size; + + let fragments = (payload_length as f32 / fragment_size as f32).ceil() as usize; + let redundancy = (fragments as f32 + * self.fuota_deployment.fragmentation_redundancy_percentage as f32 + / 100.0) + .ceil() as usize; + + let mut payload = self.fuota_deployment.payload.clone(); + payload.extend_from_slice(&vec![0; padding]); + + let encoded_fragments = fragmentation::v1::encode(&payload, fragment_size, redundancy)?; + + for (i, frag) in encoded_fragments.iter().enumerate() { + let pl = + fragmentation::v1::Payload::DataFragment(fragmentation::v1::DataFragmentPayload { + index_and_n: fragmentation::v1::DataFragmentPayloadIndexAndN { + frag_index: 0, + n: (i + 1) as u16, + }, + data: frag.clone(), + }); + + let _ = downlink::multicast::enqueue(multicast::MulticastGroupQueueItem { + multicast_group_id: self.fuota_deployment.id, + f_port: self.device_profile.app_layer_params.ts004_f_port as i16, + data: pl.to_vec()?, + ..Default::default() + }) + .await?; + } + + match self.fuota_deployment.request_fragmentation_session_status { + RequestFragmentationSessionStatus::NoRequest => { + Ok(Some((FuotaJob::Complete, Utc::now()))) + } + RequestFragmentationSessionStatus::AfterFragEnqueue => { + Ok(Some((FuotaJob::FragStatus, Utc::now()))) + } + RequestFragmentationSessionStatus::AfterSessTimeout => { + let timeout = match self.fuota_deployment.multicast_group_type.as_ref() { + "B" => Duration::from_secs( + 128 * (1 << self.fuota_deployment.multicast_timeout as u64), + ), + "C" => Duration::from_secs(1 << self.fuota_deployment.multicast_timeout as u64), + _ => return Err(anyhow!("Invalid multicast-group type")), + }; + Ok(Some((FuotaJob::FragStatus, Utc::now() + timeout))) + } + } + } + + async fn fragmentation_status(&mut self) -> Result)>> { + // Proceed with next step after reaching the max attempts. + if self.job.attempt_count > self.job.max_retry_count { + return Ok(Some((FuotaJob::Complete, Utc::now()))); + } + + info!("Enqueue FragSessionStatusReq"); + self.job.attempt_count += 1; + + let fuota_devices = fuota::get_devices(self.job.fuota_deployment_id.into(), -1, 0).await?; + + // Filter on devices that have completed the multicast-session setup but + // not yet responded to the FragSessionStatusReq. + let fuota_devices: Vec = fuota_devices + .into_iter() + .filter(|d| d.mc_session_completed_at.is_some() && d.frag_status_completed_at.is_none()) + .collect(); + + for fuota_dev in &fuota_devices { + let pl = fragmentation::v1::Payload::FragSessionStatusReq( + fragmentation::v1::FragSessionStatusReqPayload { + participants: true, + frag_index: 0, + }, + ); + + device_queue::enqueue_item(device_queue::DeviceQueueItem { + dev_eui: fuota_dev.dev_eui, + f_port: self.device_profile.app_layer_params.ts004_f_port.into(), + data: pl.to_vec()?, + ..Default::default() + }) + .await?; + } + + if !fuota_devices.is_empty() { + // There are devices pending setup, we need to re-run this job. + let scheduler_run_after = + Utc::now() + TimeDelta::seconds(self.device_profile.uplink_interval as i64); + Ok(Some((FuotaJob::FragStatus, scheduler_run_after))) + } else { + Ok(Some((FuotaJob::Complete, Utc::now()))) + } + } + + async fn complete(&mut self) -> Result)>> { + // Proceed with next step after reaching the max attempts. + if self.job.attempt_count > self.job.max_retry_count { + return Ok(None); + } + + info!("Complete FUOTA deployment"); + self.job.attempt_count += 1; + self.fuota_deployment.completed_at = Some(Utc::now()); + + if self.fuota_deployment.request_fragmentation_session_status + == RequestFragmentationSessionStatus::NoRequest + { + fuota::set_device_completed(self.fuota_deployment.id.into(), true, true, true, false) + .await?; + } else { + fuota::set_device_completed(self.fuota_deployment.id.into(), true, true, true, true) + .await?; + fuota::set_device_timeout_error( + self.fuota_deployment.id.into(), + false, + false, + false, + true, + ) + .await?; + } + + Ok(None) + } +} diff --git a/chirpstack/src/applayer/fuota/mod.rs b/chirpstack/src/applayer/fuota/mod.rs new file mode 100644 index 00000000..842d2922 --- /dev/null +++ b/chirpstack/src/applayer/fuota/mod.rs @@ -0,0 +1,9 @@ +use tracing::info; + +pub mod flow; +pub mod scheduler; + +pub async fn setup() { + info!("Setting up FUOTA scheduler loop"); + tokio::spawn(scheduler::scheduler_loop()); +} diff --git a/chirpstack/src/applayer/fuota/scheduler.rs b/chirpstack/src/applayer/fuota/scheduler.rs new file mode 100644 index 00000000..f684792e --- /dev/null +++ b/chirpstack/src/applayer/fuota/scheduler.rs @@ -0,0 +1,45 @@ +use anyhow::Result; +use tokio::time::sleep; +use tracing::{error, span, trace, Instrument, Level}; + +use crate::applayer::fuota::flow; +use crate::config; +use crate::storage::fuota; + +pub async fn scheduler_loop() { + let conf = config::get(); + + loop { + trace!("Starting fuota scheduler_loop run"); + if let Err(err) = schedule_batch(conf.network.scheduler.batch_size).await { + error!(error = %err, "Scheduling FUOTA batch error"); + } else { + trace!("schedule_batch completed without error"); + } + sleep(conf.network.scheduler.interval).await; + } +} + +async fn schedule_batch(size: usize) -> Result<()> { + trace!("Get schedulable fuota jobs"); + let jobs = fuota::get_schedulable_jobs(size).await?; + trace!(job_count = jobs.len(), "Got this number of fuota jobs"); + + let mut handles = vec![]; + + for job in jobs { + // Spawn the batch as async tasks. + let handle = tokio::spawn(async move { + let span = span!(Level::INFO, "job", fuota_deployment_id = %job.fuota_deployment_id, job = %job.job); + + if let Err(e) = flow::Flow::handle_job(job).instrument(span).await { + error!(error = %e, "Handle FUOTA job error"); + } + }); + handles.push(handle); + } + + futures::future::join_all(handles).await; + + Ok(()) +} diff --git a/chirpstack/src/applayer/mod.rs b/chirpstack/src/applayer/mod.rs index ef27a556..f15e3fc7 100644 --- a/chirpstack/src/applayer/mod.rs +++ b/chirpstack/src/applayer/mod.rs @@ -5,6 +5,9 @@ use crate::storage::{device, device_profile}; use chirpstack_api::gw; pub mod clocksync; +pub mod fragmentation; +pub mod fuota; +pub mod multicastsetup; pub async fn handle_uplink( dev: &device::Device, @@ -31,9 +34,15 @@ async fn _handle_uplink( .instrument(span) .await } else if dp.app_layer_params.ts004_f_port == f_port { - unimplemented!() + let span = span!(Level::INFO, "ts004"); + fragmentation::handle_uplink(dev, dp, data) + .instrument(span) + .await } else if dp.app_layer_params.ts005_f_port == f_port { - unimplemented!() + let span = span!(Level::INFO, "ts005"); + multicastsetup::handle_uplink(dev, dp, data) + .instrument(span) + .await } else { return Err(anyhow!("Unexpected f_port {}", f_port)); } diff --git a/chirpstack/src/applayer/multicastsetup.rs b/chirpstack/src/applayer/multicastsetup.rs new file mode 100644 index 00000000..c070273e --- /dev/null +++ b/chirpstack/src/applayer/multicastsetup.rs @@ -0,0 +1,139 @@ +use anyhow::Result; +use chrono::Utc; +use tracing::{info, warn}; + +use crate::storage::fields::device_profile::Ts005Version; +use crate::storage::{device, device_profile, fuota}; +use lrwn::applayer::multicastsetup; + +pub async fn handle_uplink( + dev: &device::Device, + dp: &device_profile::DeviceProfile, + data: &[u8], +) -> Result<()> { + let version = dp + .app_layer_params + .ts005_version + .ok_or_else(|| anyhow!("Device does not support TS005"))?; + + match version { + Ts005Version::V100 => handle_uplink_v100(dev, data).await, + } +} + +async fn handle_uplink_v100(dev: &device::Device, data: &[u8]) -> Result<()> { + let pl = multicastsetup::v1::Payload::from_slice(true, data)?; + + match pl { + multicastsetup::v1::Payload::McGroupSetupAns(pl) => { + handle_v1_mc_group_setup_ans(dev, pl).await? + } + multicastsetup::v1::Payload::McClassBSessionAns(pl) => { + handle_v1_mc_class_b_session_ans(dev, pl).await? + } + multicastsetup::v1::Payload::McClassCSessionAns(pl) => { + handle_v1_mc_class_c_session_ans(dev, pl).await? + } + _ => {} + } + + Ok(()) +} + +async fn handle_v1_mc_group_setup_ans( + dev: &device::Device, + pl: multicastsetup::v1::McGroupSetupAnsPayload, +) -> Result<()> { + info!("Handling McGroupSetupAns"); + + let mut fuota_dev = fuota::get_latest_device_by_dev_eui(dev.dev_eui).await?; + + if pl.mc_group_id_header.id_error { + warn!( + mc_group_id = pl.mc_group_id_header.mc_group_id, + id_error = true, + "McGroupSetupAns contains errors" + ); + fuota_dev.error_msg = "Error: McGroupSetupAns response id_error=true".into(); + } else { + fuota_dev.mc_group_setup_completed_at = Some(Utc::now()); + } + + let _ = fuota::update_device(fuota_dev).await?; + + Ok(()) +} + +async fn handle_v1_mc_class_b_session_ans( + dev: &device::Device, + pl: multicastsetup::v1::McClassBSessionAnsPayload, +) -> Result<()> { + info!("Handling McClassBSessionAns"); + + let mut fuota_dev = fuota::get_latest_device_by_dev_eui(dev.dev_eui).await?; + + if pl.status_and_mc_group_id.dr_error + | pl.status_and_mc_group_id.freq_error + | pl.status_and_mc_group_id.mc_group_undefined + { + warn!( + dr_error = pl.status_and_mc_group_id.dr_error, + freq_error = pl.status_and_mc_group_id.freq_error, + mc_group_undefined = pl.status_and_mc_group_id.mc_group_undefined, + "McClassBSessionAns contains errors" + ); + + fuota_dev.error_msg= format!("Error: McClassBSessionAns response dr_error: {}, freq_error: {}, mc_group_undefined: {}", + pl.status_and_mc_group_id.dr_error, + pl.status_and_mc_group_id.freq_error, + pl.status_and_mc_group_id.mc_group_undefined, + ); + } else { + info!( + time_to_start = pl.time_to_start.unwrap_or_default(), + "McClassBSessionAns OK" + ); + fuota_dev.mc_session_completed_at = Some(Utc::now()); + } + + let _ = fuota::update_device(fuota_dev).await?; + + Ok(()) +} + +async fn handle_v1_mc_class_c_session_ans( + dev: &device::Device, + pl: multicastsetup::v1::McClassCSessionAnsPayload, +) -> Result<()> { + info!("Handling McClassCSessionAns"); + + let mut fuota_dev = fuota::get_latest_device_by_dev_eui(dev.dev_eui).await?; + + if pl.status_and_mc_group_id.dr_error + | pl.status_and_mc_group_id.freq_error + | pl.status_and_mc_group_id.mc_group_undefined + { + warn!( + dr_error = pl.status_and_mc_group_id.dr_error, + freq_error = pl.status_and_mc_group_id.freq_error, + mc_group_undefined = pl.status_and_mc_group_id.mc_group_undefined, + "McClassCSessionAns contains errors" + ); + + fuota_dev.error_msg = format!("Error: McClassCSessionAns response dr_error: {}, freq_error: {}, mc_group_undefined: {}", + pl.status_and_mc_group_id.dr_error, + pl.status_and_mc_group_id.freq_error, + pl.status_and_mc_group_id.mc_group_undefined, + ); + } else { + info!( + time_to_start = pl.time_to_start.unwrap_or_default(), + "McClassCSessionAns OK" + ); + fuota_dev.mc_session_completed_at = Some(Utc::now()); + } + + let _ = fuota::update_device(fuota_dev).await?; + + Ok(()) +} diff --git a/chirpstack/src/cmd/root.rs b/chirpstack/src/cmd/root.rs index c4dc3d0c..e44bca17 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, backend, downlink, integration, region, storage}; +use crate::{adr, api, applayer::fuota, backend, downlink, integration, region, storage}; pub async fn run() -> Result<()> { info!( @@ -21,6 +21,7 @@ pub async fn run() -> Result<()> { integration::setup().await?; gateway::backend::setup().await?; downlink::setup().await; + fuota::setup().await; api::setup().await?; let mut signals = Signals::new([SIGINT, SIGTERM]).unwrap(); diff --git a/chirpstack/src/storage/fields/fuota.rs b/chirpstack/src/storage/fields/fuota.rs index 56c62691..d1cbe83c 100644 --- a/chirpstack/src/storage/fields/fuota.rs +++ b/chirpstack/src/storage/fields/fuota.rs @@ -91,6 +91,7 @@ pub enum FuotaJob { FragSessionSetup, Enqueue, FragStatus, + Complete, } impl fmt::Display for FuotaJob { @@ -110,6 +111,7 @@ impl From<&FuotaJob> for String { FuotaJob::FragSessionSetup => "FRAG_SESSION_SETUP", FuotaJob::Enqueue => "ENQUEUE", FuotaJob::FragStatus => "FRAG_STATUS", + FuotaJob::Complete => "COMPLETE", } .to_string() } @@ -128,6 +130,7 @@ impl TryFrom<&str> for FuotaJob { "FRAG_SESSION_SETUP" => Self::FragSessionSetup, "ENQUEUE" => Self::Enqueue, "FRAG_STATUS" => Self::FragStatus, + "COMPLETE" => Self::Complete, _ => return Err(anyhow!("Invalid FuotaJob value: {}", value)), }) } diff --git a/chirpstack/src/storage/fields/mod.rs b/chirpstack/src/storage/fields/mod.rs index 531915fa..77ff8a85 100644 --- a/chirpstack/src/storage/fields/mod.rs +++ b/chirpstack/src/storage/fields/mod.rs @@ -10,10 +10,7 @@ mod uuid; pub use big_decimal::BigDecimal; pub use dev_nonces::DevNonces; -pub use device_profile::{ - AbpParams, AppLayerParams, ClassBParams, ClassCParams, RelayParams, Ts003Version, Ts004Version, - Ts005Version, -}; +pub use device_profile::{AbpParams, AppLayerParams, ClassBParams, ClassCParams, RelayParams}; pub use device_session::DeviceSession; pub use fuota::{FuotaJob, RequestFragmentationSessionStatus}; pub use key_value::KeyValue; diff --git a/chirpstack/src/storage/fuota.rs b/chirpstack/src/storage/fuota.rs index 18966631..042c33fb 100644 --- a/chirpstack/src/storage/fuota.rs +++ b/chirpstack/src/storage/fuota.rs @@ -96,12 +96,12 @@ pub struct FuotaDeploymentDevice { pub fuota_deployment_id: fields::Uuid, pub dev_eui: EUI64, pub created_at: DateTime, - pub updated_at: DateTime, + pub completed_at: Option>, pub mc_group_setup_completed_at: Option>, pub mc_session_completed_at: Option>, pub frag_session_setup_completed_at: Option>, pub frag_status_completed_at: Option>, - pub return_msg: String, + pub error_msg: String, } impl Default for FuotaDeploymentDevice { @@ -112,12 +112,12 @@ impl Default for FuotaDeploymentDevice { fuota_deployment_id: Uuid::nil().into(), dev_eui: EUI64::default(), created_at: now, - updated_at: now, + completed_at: None, mc_group_setup_completed_at: None, mc_session_completed_at: None, frag_session_setup_completed_at: None, frag_status_completed_at: None, - return_msg: "".into(), + error_msg: "".into(), } } } @@ -150,7 +150,7 @@ pub struct FuotaDeploymentJob { pub max_retry_count: i16, pub attempt_count: i16, pub scheduler_run_after: DateTime, - pub return_msg: String, + pub error_msg: String, } impl Default for FuotaDeploymentJob { @@ -165,7 +165,7 @@ impl Default for FuotaDeploymentJob { max_retry_count: 0, attempt_count: 0, scheduler_run_after: now, - return_msg: "".into(), + error_msg: "".into(), } } } @@ -351,12 +351,10 @@ pub async fn get_devices( .map_err(|e| Error::from_diesel(e, "".into())) } -pub async fn get_device( - fuota_deployment_id: Uuid, - dev_eui: EUI64, -) -> Result { +pub async fn get_latest_device_by_dev_eui(dev_eui: EUI64) -> Result { fuota_deployment_device::dsl::fuota_deployment_device - .find((&fields::Uuid::from(fuota_deployment_id), &dev_eui)) + .filter(fuota_deployment_device::dsl::dev_eui.eq(&dev_eui)) + .order_by(fuota_deployment_device::created_at.desc()) .first(&mut get_async_db_conn().await?) .await .map_err(|e| Error::from_diesel(e, dev_eui.to_string())) @@ -368,13 +366,13 @@ pub async fn update_device(d: FuotaDeploymentDevice) -> Result Result { .map_err(|e| Error::from_diesel(e, "".into())) } +pub async fn set_device_timeout_error( + fuota_deployment_id: Uuid, + mc_group_setup_timeout: bool, + mc_session_timeout: bool, + frag_session_setup_timeout: bool, + frag_status_timeout: bool, +) -> Result<()> { + let fuota_deployment_id = fields::Uuid::from(fuota_deployment_id); + + let mut error_msg = String::new(); + if mc_group_setup_timeout { + error_msg = "McGroupSetupReq timeout.".into(); + } + if mc_session_timeout { + error_msg = "McSessionReq timeout".into(); + } + if frag_session_setup_timeout { + error_msg = "FragSessionSetupReq timeout.".into(); + } + if frag_status_timeout { + error_msg = "FragStatusReq timeout.".into(); + } + + let mut q = diesel::update(fuota_deployment_device::table) + .set(fuota_deployment_device::dsl::error_msg.eq(&error_msg)) + .filter(fuota_deployment_device::dsl::fuota_deployment_id.eq(&fuota_deployment_id)) + .filter(fuota_deployment_device::dsl::error_msg.is_not_null()) + .into_boxed(); + + if mc_group_setup_timeout { + q = q.filter(fuota_deployment_device::dsl::mc_group_setup_completed_at.is_null()); + } + + if mc_session_timeout { + q = q.filter(fuota_deployment_device::dsl::mc_session_completed_at.is_null()); + } + + if frag_session_setup_timeout { + q = q.filter(fuota_deployment_device::dsl::frag_session_setup_completed_at.is_null()); + } + + if frag_status_timeout { + q = q.filter(fuota_deployment_device::dsl::frag_status_completed_at.is_null()); + } + + q.execute(&mut get_async_db_conn().await?).await?; + + Ok(()) +} + +pub async fn set_device_completed( + fuota_deployment_id: Uuid, + mc_group_setup_completed: bool, + mc_session_completed: bool, + frag_session_setup_completed: bool, + frag_status_completed: bool, +) -> Result<()> { + let fuota_deployment_id = fields::Uuid::from(fuota_deployment_id); + + let mut q = diesel::update(fuota_deployment_device::table) + .set(fuota_deployment_device::dsl::completed_at.eq(Some(Utc::now()))) + .filter(fuota_deployment_device::dsl::fuota_deployment_id.eq(&fuota_deployment_id)) + .into_boxed(); + + if mc_group_setup_completed { + q = q.filter(fuota_deployment_device::dsl::mc_group_setup_completed_at.is_not_null()); + } + + if mc_session_completed { + q = q.filter(fuota_deployment_device::dsl::mc_session_completed_at.is_not_null()); + } + + if frag_session_setup_completed { + q = q.filter(fuota_deployment_device::dsl::frag_session_setup_completed_at.is_not_null()); + } + + if frag_status_completed { + q = q.filter(fuota_deployment_device::dsl::frag_status_completed_at.is_not_null()); + } + + q.execute(&mut get_async_db_conn().await?).await?; + + Ok(()) +} + pub async fn add_gateways(fuota_deployment_id: Uuid, gateway_ids: Vec) -> Result<(), Error> { let mut errors = Vec::new(); @@ -439,7 +522,7 @@ pub async fn add_gateways(fuota_deployment_id: Uuid, gateway_ids: Vec) -> let res = diesel::insert_into(fuota_deployment_gateway::table) .values(&FuotaDeploymentGateway { fuota_deployment_id: fuota_deployment_id.into(), - gateway_id: gateway_id, + gateway_id, ..Default::default() }) .execute(&mut get_async_db_conn().await?) @@ -554,7 +637,7 @@ pub async fn update_job(j: FuotaDeploymentJob) -> Result Result { .n - 3; - Ok(max_pl_size) + Ok(if max_pl_size > d.payload.len() { + d.payload.len() + } else { + max_pl_size + }) } pub fn get_multicast_timeout(d: &FuotaDeployment) -> Result { @@ -878,10 +965,10 @@ mod test { assert_eq!(d.id, devices[0].fuota_deployment_id); // get device - let mut fuota_d = get_device(d.id.into(), dev.dev_eui).await.unwrap(); - fuota_d.return_msg = "Error: kaboom".into(); - let fuota_d = update_device(fuota_d).await.unwrap(); - assert_eq!("Error: kaboom", fuota_d.return_msg); + let mut devices = get_devices(d.id.into(), 1, 0).await.unwrap(); + devices[0].error_msg = "Error: kaboom".into(); + let fuota_d = update_device(devices[0].clone()).await.unwrap(); + assert_eq!("Error: kaboom", fuota_d.error_msg); // remove devices remove_devices(d.id.into(), vec![dev.dev_eui]) @@ -1095,6 +1182,7 @@ mod test { device_profile_id: dp.id, name: "test-fuota-deployment".into(), multicast_dr: 5, + payload: vec![0; 1000], ..Default::default() }) .await diff --git a/chirpstack/src/storage/schema_postgres.rs b/chirpstack/src/storage/schema_postgres.rs index 6cb64b6e..36df751c 100644 --- a/chirpstack/src/storage/schema_postgres.rs +++ b/chirpstack/src/storage/schema_postgres.rs @@ -221,12 +221,12 @@ diesel::table! { fuota_deployment_id -> Uuid, dev_eui -> Bytea, created_at -> Timestamptz, - updated_at -> Timestamptz, + completed_at -> Nullable, mc_group_setup_completed_at -> Nullable, mc_session_completed_at -> Nullable, frag_session_setup_completed_at -> Nullable, frag_status_completed_at -> Nullable, - return_msg -> Text, + error_msg -> Text, } } @@ -248,7 +248,7 @@ diesel::table! { max_retry_count -> Int2, attempt_count -> Int2, scheduler_run_after -> Timestamptz, - return_msg -> Text, + error_msg -> Text, } } diff --git a/chirpstack/src/storage/schema_sqlite.rs b/chirpstack/src/storage/schema_sqlite.rs index 6df4a2f0..72265e0b 100644 --- a/chirpstack/src/storage/schema_sqlite.rs +++ b/chirpstack/src/storage/schema_sqlite.rs @@ -197,12 +197,12 @@ diesel::table! { fuota_deployment_id -> Text, dev_eui -> Binary, created_at -> TimestamptzSqlite, - updated_at -> TimestamptzSqlite, + completed_at -> Nullable, mc_group_setup_completed_at -> Nullable, mc_session_completed_at -> Nullable, frag_session_setup_completed_at -> Nullable, frag_status_completed_at -> Nullable, - return_msg -> Text, + error_msg -> Text, } } @@ -223,7 +223,7 @@ diesel::table! { max_retry_count -> SmallInt, attempt_count -> SmallInt, scheduler_run_after -> TimestamptzSqlite, - return_msg -> Text, + error_msg -> Text, } } diff --git a/ui/src/stores/FuotaStore.ts b/ui/src/stores/FuotaStore.ts index dc3d77b4..6f8bff12 100644 --- a/ui/src/stores/FuotaStore.ts +++ b/ui/src/stores/FuotaStore.ts @@ -21,6 +21,8 @@ import type { ListFuotaDeploymentGatewaysRequest, ListFuotaDeploymentGatewaysResponse, StartFuotaDeploymentRequest, + ListFuotaDeploymentJobsRequest, + ListFuotaDeploymentJobsResponse, } from "@chirpstack/chirpstack-api-grpc-web/api/fuota_pb"; import SessionStore from "./SessionStore"; @@ -40,7 +42,7 @@ class FuotaStore extends EventEmitter { callbackFunc: (resp: CreateFuotaDeploymentResponse) => void, ) => { this.client.createDeployment(req, SessionStore.getMetadata(), (err, resp) => { - if (err != null) { + if (err !== null) { HandleError(err); return; } @@ -56,7 +58,7 @@ class FuotaStore extends EventEmitter { getDeployment = (req: GetFuotaDeploymentRequest, callbackFunc: (resp: GetFuotaDeploymentResponse) => void) => { this.client.getDeployment(req, SessionStore.getMetadata(), (err, resp) => { - if (err != null) { + if (err !== null) { HandleError(err); return; } @@ -67,7 +69,7 @@ class FuotaStore extends EventEmitter { updateDeployment = (req: UpdateFuotaDeploymentRequest, callbackFunc: () => void) => { this.client.updateDeployment(req, SessionStore.getMetadata(), err => { - if (err != null) { + if (err !== null) { HandleError(err); return; } @@ -85,7 +87,7 @@ class FuotaStore extends EventEmitter { deleteDeployment = (req: DeleteFuotaDeploymentRequest, callbackFunc: () => void) => { this.client.deleteDeployment(req, SessionStore.getMetadata(), err => { - if (err != null) { + if (err !== null) { HandleError(err); return; } @@ -101,7 +103,7 @@ class FuotaStore extends EventEmitter { startDeployment = (req: StartFuotaDeploymentRequest, callbackFunc: () => void) => { this.client.startDeployment(req, SessionStore.getMetadata(), err => { - if (err != null) { + if (err !== null) { HandleError(err); return; } @@ -119,7 +121,7 @@ class FuotaStore extends EventEmitter { listDeployments = (req: ListFuotaDeploymentsRequest, callbackFunc: (resp: ListFuotaDeploymentsResponse) => void) => { this.client.listDeployments(req, SessionStore.getMetadata(), (err, resp) => { - if (err != null) { + if (err !== null) { HandleError(err); return; } @@ -130,7 +132,7 @@ class FuotaStore extends EventEmitter { addDevices = (req: AddDevicesToFuotaDeploymentRequest, callbackFunc: () => void) => { this.client.addDevices(req, SessionStore.getMetadata(), err => { - if (err != null) { + if (err !== null) { HandleError(err); return; } @@ -146,7 +148,7 @@ class FuotaStore extends EventEmitter { removeDevices = (req: RemoveDevicesFromFuotaDeploymentRequest, callbackFunc: () => void) => { this.client.removeDevices(req, SessionStore.getMetadata(), err => { - if (err != null) { + if (err !== null) { HandleError(err); return; } @@ -165,7 +167,7 @@ class FuotaStore extends EventEmitter { callbackFunc: (resp: ListFuotaDeploymentDevicesResponse) => void, ) => { this.client.listDevices(req, SessionStore.getMetadata(), (err, resp) => { - if (err != null) { + if (err !== null) { HandleError(err); return; } @@ -176,7 +178,7 @@ class FuotaStore extends EventEmitter { addGateways = (req: AddGatewaysToFuotaDeploymentRequest, callbackFunc: () => void) => { this.client.addGateways(req, SessionStore.getMetadata(), err => { - if (err != null) { + if (err !== null) { HandleError(err); return; } @@ -192,7 +194,7 @@ class FuotaStore extends EventEmitter { removeGateways = (req: RemoveGatewaysFromFuotaDeploymentRequest, callbackFunc: () => void) => { this.client.removeGateways(req, SessionStore.getMetadata(), err => { - if (err != null) { + if (err !== null) { HandleError(err); return; } @@ -211,7 +213,18 @@ class FuotaStore extends EventEmitter { callbackFunc: (resp: ListFuotaDeploymentGatewaysResponse) => void, ) => { this.client.listGateways(req, SessionStore.getMetadata(), (err, resp) => { - if (err != null) { + if (err !== null) { + HandleError(err); + return; + } + + callbackFunc(resp); + }); + }; + + listJobs = (req: ListFuotaDeploymentJobsRequest, callbackFunc: (resp: ListFuotaDeploymentJobsResponse) => void) => { + this.client.listJobs(req, SessionStore.getMetadata(), (err, resp) => { + if (err !== null) { HandleError(err); return; } diff --git a/ui/src/views/device-profiles/CreateDeviceProfile.tsx b/ui/src/views/device-profiles/CreateDeviceProfile.tsx index 9bdd47a0..562edba4 100644 --- a/ui/src/views/device-profiles/CreateDeviceProfile.tsx +++ b/ui/src/views/device-profiles/CreateDeviceProfile.tsx @@ -5,7 +5,11 @@ import { PageHeader } from "@ant-design/pro-layout"; import { MacVersion, RegParamsRevision } from "@chirpstack/chirpstack-api-grpc-web/common/common_pb"; import type { CreateDeviceProfileResponse } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb"; -import { DeviceProfile, CreateDeviceProfileRequest, AppLayerParams } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb"; +import { + DeviceProfile, + CreateDeviceProfileRequest, + AppLayerParams, +} from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb"; import type { Tenant } from "@chirpstack/chirpstack-api-grpc-web/api/tenant_pb"; diff --git a/ui/src/views/device-profiles/DeviceProfileForm.tsx b/ui/src/views/device-profiles/DeviceProfileForm.tsx index 9fb9f294..59c589ae 100644 --- a/ui/src/views/device-profiles/DeviceProfileForm.tsx +++ b/ui/src/views/device-profiles/DeviceProfileForm.tsx @@ -1081,10 +1081,7 @@ function DeviceProfileForm(props: IProps) { - + @@ -1103,10 +1100,7 @@ function DeviceProfileForm(props: IProps) { - + @@ -1125,10 +1119,7 @@ function DeviceProfileForm(props: IProps) { - + diff --git a/ui/src/views/devices/ListDevices.tsx b/ui/src/views/devices/ListDevices.tsx index 585b3fd1..5f0b7541 100644 --- a/ui/src/views/devices/ListDevices.tsx +++ b/ui/src/views/devices/ListDevices.tsx @@ -36,7 +36,7 @@ import { } from "@chirpstack/chirpstack-api-grpc-web/api/multicast_group_pb"; import type { ListRelaysResponse, RelayListItem } from "@chirpstack/chirpstack-api-grpc-web/api/relay_pb"; import { ListRelaysRequest, AddRelayDeviceRequest } from "@chirpstack/chirpstack-api-grpc-web/api/relay_pb"; -import { +import type { ListFuotaDeploymentsResponse, FuotaDeploymentListItem, } from "@chirpstack/chirpstack-api-grpc-web/api/fuota_pb"; diff --git a/ui/src/views/fuota/FuotaDeploymentDashboard.tsx b/ui/src/views/fuota/FuotaDeploymentDashboard.tsx new file mode 100644 index 00000000..5039adb1 --- /dev/null +++ b/ui/src/views/fuota/FuotaDeploymentDashboard.tsx @@ -0,0 +1,128 @@ +import { useState, useEffect } from "react"; + +import { Spin, Button, Space, Timeline, Row, Col, TimelineProps, Card, Tag, Popover, Table } from "antd"; +import { LoadingOutlined, ReloadOutlined } from "@ant-design/icons"; +import type { ColumnsType } from "antd/es/table"; +import { format } from "date-fns"; + +import { format_dt, format_dt_from_secs } from "../helpers"; + +import { ListFuotaDeploymentJobsRequest } from "@chirpstack/chirpstack-api-grpc-web/api/fuota_pb"; + +import { + GetFuotaDeploymentResponse, + ListFuotaDeploymentJobsResponse, + FuotaDeploymentJob, +} from "@chirpstack/chirpstack-api-grpc-web/api/fuota_pb"; + +import FuotaStore from "../../stores/FuotaStore"; + +interface IProps { + getFuotaDeploymentResponse: GetFuotaDeploymentResponse; +} + +function FuotaDeploymentDashboard(props: IProps) { + const [fuotaJobs, setFuotaJobs] = useState([]); + + useEffect(() => { + getFuotaJobs(); + + const interval = setInterval(() => { + if (!props.getFuotaDeploymentResponse.getCompletedAt()) { + getFuotaJobs(); + } + }, 10000); + + return () => clearInterval(interval); + }, [props.getFuotaDeploymentResponse]); + + const jobs: Record = { + CREATE_MC_GROUP: "Create multicast group", + ADD_DEVS_TO_MC_GROUP: "Add devices to multicast group", + ADD_GWS_TO_MC_GROUP: "Add gateways to multicast group", + MC_GROUP_SETUP: "Multicast group setup", + FRAG_SESSION_SETUP: "Fragmentation session setup", + MC_SESSION: "Multicast session setup", + ENQUEUE: "Enqueue fragments", + FRAG_STATUS: "Request fragmentation status", + COMPLETE: "Complete deployment", + }; + + const columns: ColumnsType = [ + { + title: "Status", + key: "status", + width: 100, + render: (_text, record) => { + if (record.errorMsg !== "") { + return ( + + error + + ); + } else if (!record.completedAt) { + return } size="small" />; + } else { + return ok; + } + }, + }, + { + title: "Job", + dataIndex: "job", + key: "job", + render: text => jobs[text], + width: 250, + }, + { + title: "Created at", + dataIndex: "createdAt", + key: "createdAt", + render: (_text, record) => format_dt_from_secs(record.createdAt?.seconds), + width: 250, + }, + { + title: "Completed at", + dataIndex: "completedAt", + key: "completedAt", + render: (_text, record) => format_dt_from_secs(record.completedAt?.seconds), + width: 250, + }, + { + title: "Attempt count", + dataIndex: "attemptCount", + key: "attemptCount", + width: 150, + }, + { + title: "Max. retry", + dataIndex: "maxRetryCount", + key: "maxRetryCount", + width: 150, + }, + ]; + + const getFuotaJobs = () => { + const req = new ListFuotaDeploymentJobsRequest(); + req.setFuotaDeploymentId(props.getFuotaDeploymentResponse.getDeployment()!.getId()); + FuotaStore.listJobs(req, (resp: ListFuotaDeploymentJobsResponse) => { + const obj = resp.toObject(); + setFuotaJobs(obj.jobsList); + }); + }; + + let loadingProps = undefined; + if (props.getFuotaDeploymentResponse.getStartedAt() && fuotaJobs.length === 0) { + loadingProps = { + delay: 300, + }; + } + + return ( + + + + ); +} + +export default FuotaDeploymentDashboard; diff --git a/ui/src/views/fuota/FuotaDeploymentDevices.tsx b/ui/src/views/fuota/FuotaDeploymentDevices.tsx index a7e95977..246e8bbb 100644 --- a/ui/src/views/fuota/FuotaDeploymentDevices.tsx +++ b/ui/src/views/fuota/FuotaDeploymentDevices.tsx @@ -1,14 +1,16 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; -import { Space, Button, Popconfirm } from "antd"; +import { Tag, Space, Button, Popconfirm, Spin, Typography, Popover } from "antd"; +import { LoadingOutlined, ZoomInOutlined } from "@ant-design/icons"; import type { ColumnsType } from "antd/es/table"; +import { format } from "date-fns"; import { ListFuotaDeploymentDevicesRequest, RemoveDevicesFromFuotaDeploymentRequest, } from "@chirpstack/chirpstack-api-grpc-web/api/fuota_pb"; import type { - FuotaDeployment, + GetFuotaDeploymentResponse, ListFuotaDeploymentDevicesResponse, FuotaDeploymentDeviceListItem, } from "@chirpstack/chirpstack-api-grpc-web/api/fuota_pb"; @@ -18,19 +20,94 @@ import DataTable from "../../components/DataTable"; import FuotaStore from "../../stores/FuotaStore"; interface IProps { - fuotaDeployment: FuotaDeployment; + getFuotaDeploymentResponse: GetFuotaDeploymentResponse; } function FuotaDeploymentDevices(props: IProps) { const [selectedRowIds, setSelectedRowIds] = useState([]); const [refreshKey, setRefreshKey] = useState(0); + useEffect(() => { + const interval = setInterval(() => { + if (!props.getFuotaDeploymentResponse.getCompletedAt()) { + setRefreshKey(refreshKey + 1); + } + }, 10000); + + return () => clearInterval(interval); + }, [props.getFuotaDeploymentResponse, refreshKey]); + const columns: ColumnsType = [ + { + title: "Status", + key: "status", + width: 100, + render: (_text, record) => { + if (record.errorMsg !== "") { + return ( + + error + + ); + } else if (record.completedAt) { + return ok; + } else if (props.getFuotaDeploymentResponse.getStartedAt()) { + return } size="small" />; + } else { + return ""; + } + }, + }, { title: "DevEUI", dataIndex: "devEui", key: "devEui", - // width: 250, + width: 250, + render: text => {text}, + }, + { + title: "Mc. group setup completed at", + key: "mcGroupSetupCompletedAt", + render: (_text, record) => { + if (record.mcGroupSetupCompletedAt !== undefined) { + const ts = new Date(0); + ts.setUTCSeconds(record.mcGroupSetupCompletedAt.seconds); + return format(ts, "yyyy-MM-dd HH:mm:ss"); + } + }, + }, + { + title: "Frag. session setup completed at", + key: "fragSessionSetupCompletedAt", + render: (_text, record) => { + if (record.fragSessionSetupCompletedAt !== undefined) { + const ts = new Date(0); + ts.setUTCSeconds(record.fragSessionSetupCompletedAt.seconds); + return format(ts, "yyyy-MM-dd HH:mm:ss"); + } + }, + }, + { + title: "Mc. session completed at", + key: "mcSessionCompletedAt", + render: (_text, record) => { + if (record.mcSessionCompletedAt !== undefined) { + const ts = new Date(0); + ts.setUTCSeconds(record.mcSessionCompletedAt.seconds); + return format(ts, "yyyy-MM-dd HH:mm:ss"); + } + }, + }, + { + title: "Frag. status completed at", + key: "fragStatusCompletedAt", + render: (_text, record) => { + if (record.fragStatusCompletedAt !== undefined) { + const ts = new Date(0); + ts.setUTCSeconds(record.fragStatusCompletedAt.seconds); + return format(ts, "yyyy-MM-dd HH:mm:ss"); + } + }, }, ]; @@ -47,7 +124,7 @@ function FuotaDeploymentDevices(props: IProps) { callbackFunc: GetPageCallbackFunc, ) => { const req = new ListFuotaDeploymentDevicesRequest(); - req.setFuotaDeploymentId(props.fuotaDeployment.getId()); + req.setFuotaDeploymentId(props.getFuotaDeploymentResponse.getDeployment()!.getId()); req.setLimit(limit); req.setOffset(offset); @@ -59,7 +136,7 @@ function FuotaDeploymentDevices(props: IProps) { const removeDevices = () => { const req = new RemoveDevicesFromFuotaDeploymentRequest(); - req.setFuotaDeploymentId(props.fuotaDeployment.getId()); + req.setFuotaDeploymentId(props.getFuotaDeploymentResponse.getDeployment()!.getId()); req.setDevEuisList(selectedRowIds); FuotaStore.removeDevices(req, () => { diff --git a/ui/src/views/fuota/FuotaDeploymentForm.tsx b/ui/src/views/fuota/FuotaDeploymentForm.tsx index 2036eec4..00fd85f2 100644 --- a/ui/src/views/fuota/FuotaDeploymentForm.tsx +++ b/ui/src/views/fuota/FuotaDeploymentForm.tsx @@ -4,7 +4,7 @@ import { Form, Input, InputNumber, Select, Row, Col, Button, Upload, UploadFile, import { UploadOutlined } from "@ant-design/icons"; import type { Tenant } from "@chirpstack/chirpstack-api-grpc-web/api/tenant_pb"; -import { FuotaDeployment } from "@chirpstack/chirpstack-api-grpc-web/api/fuota_pb"; +import { FuotaDeployment, RequestFragmentationSessionStatus } from "@chirpstack/chirpstack-api-grpc-web/api/fuota_pb"; import type { ListDeviceProfilesResponse, GetDeviceProfileResponse, @@ -72,6 +72,7 @@ function FuotaDeploymentForm(props: IProps) { d.setMulticastDr(v.multicastDr); d.setMulticastFrequency(v.multicastFrequency); d.setFragmentationRedundancyPercentage(v.fragmentationRedundancyPercentage); + d.setRequestFragmentationSessionStatus(v.requestFragmentationSessionStatus); d.setCalculateMulticastTimeout(v.calculateMulticastTimeout); d.setMulticastTimeout(v.multicastTimeout); d.setCalculateFragmentationFragmentSize(v.calculateFragmentationFragmentSize); @@ -244,6 +245,25 @@ function FuotaDeploymentForm(props: IProps) { + + + + + + + {text}, }, ]; diff --git a/ui/src/views/fuota/FuotaDeploymentLayout.tsx b/ui/src/views/fuota/FuotaDeploymentLayout.tsx index e7e57d4e..8f04ad8e 100644 --- a/ui/src/views/fuota/FuotaDeploymentLayout.tsx +++ b/ui/src/views/fuota/FuotaDeploymentLayout.tsx @@ -21,6 +21,7 @@ import { useTitle } from "../helpers"; import EditFuotaDeployment from "./EditFuotaDeployment"; import FuotaDeploymentDevices from "./FuotaDeploymentDevices"; import FuotaDeploymentGateways from "./FuotaDeploymentGateways"; +import FuotaDeploymentDashboard from "./FuotaDeploymentDashboard"; interface IProps { tenant: Tenant; @@ -202,6 +203,10 @@ function FuotaDeploymentLayout(props: IProps) { + } + /> } /> - } /> + } + /> } /> diff --git a/ui/src/views/gateways/ListGateways.tsx b/ui/src/views/gateways/ListGateways.tsx index e2e82d25..4ae75ed8 100644 --- a/ui/src/views/gateways/ListGateways.tsx +++ b/ui/src/views/gateways/ListGateways.tsx @@ -142,7 +142,7 @@ function ListGateways(props: IProps) { req.setTenantId(props.tenant.getId()); let mgGroups: MulticastGroup[] = []; - let fuotaDeployments: FuotaDeployment[] = []; + let fDeployments: FuotaDeployment[] = []; ApplicationStore.list(req, (resp: ListApplicationsResponse) => { for (const app of resp.getResultList()) { @@ -169,7 +169,7 @@ function ListGateways(props: IProps) { fuotaReq.setLimit(999); fuotaReq.setApplicationId(app.getId()); FuotaStore.listDeployments(fuotaReq, (resp: ListFuotaDeploymentsResponse) => { - fuotaDeployments.push({ + fDeployments.push({ title: app.getName(), value: "", disabled: true, @@ -181,7 +181,7 @@ function ListGateways(props: IProps) { // The above can also be done using setFuotaDeployments and a callback // function, but this introduces a race-condition when executed twice. - setFuotaDeployments(fuotaDeployments); + setFuotaDeployments(fDeployments); }); } }); diff --git a/ui/src/views/helpers.ts b/ui/src/views/helpers.ts index 81387716..f60375b9 100644 --- a/ui/src/views/helpers.ts +++ b/ui/src/views/helpers.ts @@ -1,4 +1,6 @@ import { notification } from "antd"; +import { format } from "date-fns"; +import * as google_protobuf_timestamp_pb from "google-protobuf/google/protobuf/timestamp_pb"; import { MacVersion, RegParamsRevision } from "@chirpstack/chirpstack-api-grpc-web/common/common_pb"; import { useRef, useEffect } from "react"; @@ -87,3 +89,23 @@ export function useTitle(...v: unknown[]) { }; }, [documentDefined, v]); } + +export function format_dt(dt?: google_protobuf_timestamp_pb.Timestamp): string { + if (dt) { + const ts = new Date(0); + ts.setUTCSeconds(dt.getSeconds()); + return format(ts, "yyyy-MM-dd HH:mm:ss"); + } else { + return ""; + } +} + +export function format_dt_from_secs(secs?: number): string { + if (secs) { + const ts = new Date(0); + ts.setUTCSeconds(secs); + return format(ts, "yyyy-MM-dd HH:mm:ss"); + } else { + return ""; + } +}