Implement full FUOTA flow + UI components.

This commit is contained in:
Orne Brocaar 2025-03-05 14:10:40 +00:00
parent b8ab0182de
commit 27f6d2cf03
30 changed files with 1451 additions and 101 deletions

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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)
);

View File

@ -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)
);

View File

@ -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

View File

@ -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<api::ListFuotaDeploymentJobsRequest>,
) -> Result<Response<api::ListFuotaDeploymentJobsResponse>, 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,

View File

@ -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()

View File

@ -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(())
}

View File

@ -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<Option<(FuotaJob, DateTime<Utc>)>> {
// 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<Option<(FuotaJob, DateTime<Utc>)>> {
// 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<Option<(FuotaJob, DateTime<Utc>)>> {
// 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<Option<(FuotaJob, DateTime<Utc>)>> {
// 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::FuotaDeploymentDevice> = 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<Option<(FuotaJob, DateTime<Utc>)>> {
// 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::FuotaDeploymentDevice> = 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<Option<(FuotaJob, DateTime<Utc>)>> {
// 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::FuotaDeploymentDevice> = 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<Option<(FuotaJob, DateTime<Utc>)>> {
// 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<Option<(FuotaJob, DateTime<Utc>)>> {
// 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::FuotaDeploymentDevice> = 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<Option<(FuotaJob, DateTime<Utc>)>> {
// 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)
}
}

View File

@ -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());
}

View File

@ -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(())
}

View File

@ -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));
}

View File

@ -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(())
}

View File

@ -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();

View File

@ -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)),
})
}

View File

@ -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;

View File

@ -96,12 +96,12 @@ pub struct FuotaDeploymentDevice {
pub fuota_deployment_id: fields::Uuid,
pub dev_eui: EUI64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
pub mc_group_setup_completed_at: Option<DateTime<Utc>>,
pub mc_session_completed_at: Option<DateTime<Utc>>,
pub frag_session_setup_completed_at: Option<DateTime<Utc>>,
pub frag_status_completed_at: Option<DateTime<Utc>>,
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<Utc>,
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<FuotaDeploymentDevice, Error> {
pub async fn get_latest_device_by_dev_eui(dev_eui: EUI64) -> Result<FuotaDeploymentDevice, Error> {
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<FuotaDeploymentDe
.find((&d.fuota_deployment_id, &d.dev_eui)),
)
.set((
fuota_deployment_device::updated_at.eq(Utc::now()),
fuota_deployment_device::completed_at.eq(&d.completed_at),
fuota_deployment_device::mc_group_setup_completed_at.eq(&d.mc_group_setup_completed_at),
fuota_deployment_device::mc_session_completed_at.eq(&d.mc_session_completed_at),
fuota_deployment_device::frag_session_setup_completed_at
.eq(&d.frag_session_setup_completed_at),
fuota_deployment_device::frag_status_completed_at.eq(&d.frag_status_completed_at),
fuota_deployment_device::return_msg.eq(&d.return_msg),
fuota_deployment_device::error_msg.eq(&d.error_msg),
))
.get_result(&mut get_async_db_conn().await?)
.await
@ -412,6 +410,91 @@ pub async fn get_device_count(fuota_deployment_id: Uuid) -> Result<i64, Error> {
.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<EUI64>) -> Result<(), Error> {
let mut errors = Vec::new();
@ -439,7 +522,7 @@ pub async fn add_gateways(fuota_deployment_id: Uuid, gateway_ids: Vec<EUI64>) ->
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<FuotaDeploymentJob, Err
fuota_deployment_job::completed_at.eq(&j.completed_at),
fuota_deployment_job::attempt_count.eq(&j.attempt_count),
fuota_deployment_job::scheduler_run_after.eq(&j.scheduler_run_after),
fuota_deployment_job::return_msg.eq(&j.return_msg),
fuota_deployment_job::error_msg.eq(&j.error_msg),
))
.get_result(&mut get_async_db_conn().await?)
.await
@ -650,7 +733,11 @@ pub async fn get_max_fragment_size(d: &FuotaDeployment) -> Result<usize> {
.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<usize> {
@ -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

View File

@ -221,12 +221,12 @@ diesel::table! {
fuota_deployment_id -> Uuid,
dev_eui -> Bytea,
created_at -> Timestamptz,
updated_at -> Timestamptz,
completed_at -> Nullable<Timestamptz>,
mc_group_setup_completed_at -> Nullable<Timestamptz>,
mc_session_completed_at -> Nullable<Timestamptz>,
frag_session_setup_completed_at -> Nullable<Timestamptz>,
frag_status_completed_at -> Nullable<Timestamptz>,
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,
}
}

View File

@ -197,12 +197,12 @@ diesel::table! {
fuota_deployment_id -> Text,
dev_eui -> Binary,
created_at -> TimestamptzSqlite,
updated_at -> TimestamptzSqlite,
completed_at -> Nullable<TimestamptzSqlite>,
mc_group_setup_completed_at -> Nullable<TimestamptzSqlite>,
mc_session_completed_at -> Nullable<TimestamptzSqlite>,
frag_session_setup_completed_at -> Nullable<TimestamptzSqlite>,
frag_status_completed_at -> Nullable<TimestamptzSqlite>,
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,
}
}

View File

@ -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;
}

View File

@ -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";

View File

@ -1081,10 +1081,7 @@ function DeviceProfileForm(props: IProps) {
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="Clock sync fPort (TS003)"
name={["appLayerParams", "ts003FPort"]}
>
<Form.Item label="Clock sync fPort (TS003)" name={["appLayerParams", "ts003FPort"]}>
<InputNumber min={0} max={255} disabled={props.disabled} />
</Form.Item>
</Col>
@ -1103,10 +1100,7 @@ function DeviceProfileForm(props: IProps) {
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="Fragmented data block transport fPort (TS004)"
name={["appLayerParams", "ts004FPort"]}
>
<Form.Item label="Fragmented data block transport fPort (TS004)" name={["appLayerParams", "ts004FPort"]}>
<InputNumber min={0} max={255} disabled={props.disabled} />
</Form.Item>
</Col>
@ -1125,10 +1119,7 @@ function DeviceProfileForm(props: IProps) {
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="Remote multicast setup fPort (TS005)"
name={["appLayerParams", "ts005FPort"]}
>
<Form.Item label="Remote multicast setup fPort (TS005)" name={["appLayerParams", "ts005FPort"]}>
<InputNumber min={0} max={255} disabled={props.disabled} />
</Form.Item>
</Col>

View File

@ -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";

View File

@ -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<FuotaDeploymentJob.AsObject[]>([]);
useEffect(() => {
getFuotaJobs();
const interval = setInterval(() => {
if (!props.getFuotaDeploymentResponse.getCompletedAt()) {
getFuotaJobs();
}
}, 10000);
return () => clearInterval(interval);
}, [props.getFuotaDeploymentResponse]);
const jobs: Record<string, string> = {
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<FuotaDeploymentJob.AsObject> = [
{
title: "Status",
key: "status",
width: 100,
render: (_text, record) => {
if (record.errorMsg !== "") {
return (
<Popover content={record.errorMsg} placement="right">
<Tag color="red">error</Tag>
</Popover>
);
} else if (!record.completedAt) {
return <Spin indicator={<LoadingOutlined spin />} size="small" />;
} else {
return <Tag color="green">ok</Tag>;
}
},
},
{
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 (
<Space style={{ width: "100%" }} direction="vertical">
<Table loading={loadingProps} dataSource={fuotaJobs} columns={columns} pagination={false} />
</Space>
);
}
export default FuotaDeploymentDashboard;

View File

@ -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<string[]>([]);
const [refreshKey, setRefreshKey] = useState<number>(0);
useEffect(() => {
const interval = setInterval(() => {
if (!props.getFuotaDeploymentResponse.getCompletedAt()) {
setRefreshKey(refreshKey + 1);
}
}, 10000);
return () => clearInterval(interval);
}, [props.getFuotaDeploymentResponse, refreshKey]);
const columns: ColumnsType<FuotaDeploymentDeviceListItem.AsObject> = [
{
title: "Status",
key: "status",
width: 100,
render: (_text, record) => {
if (record.errorMsg !== "") {
return (
<Popover content={record.errorMsg} placement="right">
<Tag color="red">error</Tag>
</Popover>
);
} else if (record.completedAt) {
return <Tag color="green">ok</Tag>;
} else if (props.getFuotaDeploymentResponse.getStartedAt()) {
return <Spin indicator={<LoadingOutlined spin />} size="small" />;
} else {
return "";
}
},
},
{
title: "DevEUI",
dataIndex: "devEui",
key: "devEui",
// width: 250,
width: 250,
render: text => <Typography.Text code>{text}</Typography.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, () => {

View File

@ -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) {
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={8}>
<Form.Item
label="Fragmentation status request"
name="requestFragmentationSessionStatus"
tooltip="After fragment enqueue is recommended for Class-A devices, after session timeout is recommended for Class-B / Class-C devices."
>
<Select disabled={props.disabled}>
<Select.Option value={RequestFragmentationSessionStatus.NO_REQUEST}>Do not request</Select.Option>
<Select.Option value={RequestFragmentationSessionStatus.AFTER_FRAGMENT_ENQUEUE}>
After fragment enqueue
</Select.Option>
<Select.Option value={RequestFragmentationSessionStatus.AFTER_SESSION_TIMEOUT}>
After session timeout
</Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={6}>
<Form.Item

View File

@ -1,6 +1,6 @@
import { useState } from "react";
import { Space, Button, Popconfirm } from "antd";
import { Space, Button, Popconfirm, Typography } from "antd";
import type { ColumnsType } from "antd/es/table";
import {
@ -30,7 +30,7 @@ function FuotaDeploymentGateways(props: IProps) {
title: "Gateway ID",
dataIndex: "gatewayId",
key: "gatewayId",
// width: 250,
render: text => <Typography.Text code>{text}</Typography.Text>,
},
];

View File

@ -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) {
</Menu.Item>
</Menu>
<Routes>
<Route
path="/"
element={<FuotaDeploymentDashboard getFuotaDeploymentResponse={getFuotaDeploymentResponse} />}
/>
<Route
path="/edit"
element={
@ -213,7 +218,10 @@ function FuotaDeploymentLayout(props: IProps) {
/>
}
/>
<Route path="/devices" element={<FuotaDeploymentDevices fuotaDeployment={d} />} />
<Route
path="/devices"
element={<FuotaDeploymentDevices getFuotaDeploymentResponse={getFuotaDeploymentResponse} />}
/>
<Route path="/gateways" element={<FuotaDeploymentGateways fuotaDeployment={d} />} />
</Routes>
</Card>

View File

@ -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);
});
}
});

View File

@ -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 "";
}
}