4253 lines
138 KiB
Rust
4253 lines
138 KiB
Rust
//! API 处理器模块 (ModemManager 版)
|
|
//!
|
|
//! 包含所有 HTTP API 的处理函数
|
|
|
|
use axum::{
|
|
extract::{Path, Query, State},
|
|
http::StatusCode,
|
|
response::IntoResponse,
|
|
Json,
|
|
};
|
|
use serde::Deserialize;
|
|
use serde_json::json;
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::process::{Command, Output};
|
|
use std::sync::atomic::Ordering;
|
|
use std::sync::{Arc, OnceLock};
|
|
use std::time::{Duration, Instant};
|
|
use tokio::sync::RwLock;
|
|
use tracing::{error, info, warn};
|
|
use zbus::Connection;
|
|
|
|
use crate::{
|
|
config::ApnConfig,
|
|
esim::EsimApiError,
|
|
models::*,
|
|
modem_manager::{
|
|
self, answer_call, apply_roaming_policy, current_sim_identity,
|
|
find_nm_modem_connection_pub, get_airplane_mode, get_band_lock_status,
|
|
get_baseband_restart_progress, get_call_by_path, get_call_settings, get_cell_location,
|
|
get_cells_data, get_data_connection_status, get_device_info_data, get_is_roaming_mm,
|
|
get_network_info_data, get_operators_list, get_radio_mode, get_signal_strength,
|
|
get_sim_info_data_with_cache, hangup_all_calls, hangup_call, list_apn_contexts,
|
|
list_current_calls, make_call, nm_set_autoconnect_pub, power_cycle_sim_for_profile_switch,
|
|
refresh_sim_details_background, register_operator_auto, register_operator_manual,
|
|
restart_baseband, scan_operators, send_sms, set_airplane_mode, set_apn_on_bearer,
|
|
set_band_lock, set_call_waiting, set_data_connection_with_apn, set_radio_mode,
|
|
sim_details_cache_missing, start_cell_monitoring, stop_cell_monitoring,
|
|
},
|
|
state::AppState,
|
|
system_event::{
|
|
codes as system_event_codes, mask_identifier, severity as system_event_severity,
|
|
status as system_event_status,
|
|
},
|
|
utils::{
|
|
connection_addresses_from_interfaces, format_uptime, get_active_interfaces, read_cpu_info,
|
|
read_cpu_load_sync, read_disk_info, read_interface_stats, read_memory_info,
|
|
read_network_interfaces, read_system_info, read_uptime, sample_cpu_usage,
|
|
},
|
|
};
|
|
|
|
const ESIM_SIM_IDENTITY_TIMEOUT_SECS: u64 = 3;
|
|
const ESIM_CACHED_SIM_IDENTITY_TIMEOUT_MS: u64 = 800;
|
|
const SMS_DB_MAINTENANCE_DELETE_THRESHOLD: usize = 100;
|
|
const SMS_DB_MAINTENANCE_DELAY_SECS: u64 = 60;
|
|
|
|
// ============ 基础接口 ============
|
|
|
|
/// 处理 OPTIONS 请求(CORS 预检)
|
|
pub async fn options_handler() -> impl IntoResponse {
|
|
StatusCode::NO_CONTENT
|
|
}
|
|
|
|
/// GET /api/health
|
|
pub async fn health_check() -> impl IntoResponse {
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"status": "ok",
|
|
"message": "Service is running",
|
|
"platform": "linux-modem",
|
|
"version": env!("CARGO_PKG_VERSION"),
|
|
})),
|
|
)
|
|
}
|
|
|
|
fn esim_error_response<T: Default>(error: EsimApiError) -> (StatusCode, Json<ApiResponse<T>>) {
|
|
let status = match error {
|
|
EsimApiError::Disabled => StatusCode::FORBIDDEN,
|
|
EsimApiError::Unavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
|
|
EsimApiError::Command(_) => StatusCode::OK,
|
|
};
|
|
(status, Json(ApiResponse::<T>::error(error.message())))
|
|
}
|
|
|
|
fn esim_command_succeeded(response: &EsimCommandResponse) -> bool {
|
|
response.code == 0
|
|
&& (response.status.is_empty()
|
|
|| response.status.eq_ignore_ascii_case("success")
|
|
|| response.status.eq_ignore_ascii_case("ok"))
|
|
}
|
|
|
|
fn esim_profile_is_active(profile: &EsimProfile) -> bool {
|
|
matches!(
|
|
profile.state.trim().to_ascii_lowercase().as_str(),
|
|
"enabled" | "active" | "1" | "true"
|
|
)
|
|
}
|
|
|
|
fn esim_profile_state_is_unknown(state: &str) -> bool {
|
|
let state = state.trim();
|
|
state.is_empty() || state.eq_ignore_ascii_case("unknown")
|
|
}
|
|
|
|
fn sort_esim_profiles_for_display(profiles: &mut [EsimProfile]) {
|
|
profiles.sort_by(|left, right| {
|
|
let left_active = esim_profile_is_active(left);
|
|
let right_active = esim_profile_is_active(right);
|
|
right_active
|
|
.cmp(&left_active)
|
|
.then_with(|| {
|
|
left.name
|
|
.to_ascii_lowercase()
|
|
.cmp(&right.name.to_ascii_lowercase())
|
|
})
|
|
.then_with(|| left.iccid.cmp(&right.iccid))
|
|
});
|
|
}
|
|
|
|
fn split_profile_operator_code(code: &str) -> (String, String) {
|
|
let digits: String = code.chars().filter(|ch| ch.is_ascii_digit()).collect();
|
|
if digits.len() >= 6 {
|
|
(digits[..3].to_string(), digits[3..6].to_string())
|
|
} else if digits.len() >= 5 {
|
|
(digits[..3].to_string(), digits[3..].to_string())
|
|
} else {
|
|
(String::new(), String::new())
|
|
}
|
|
}
|
|
|
|
fn enrich_profiles_with_current_identity(
|
|
profiles: &mut [EsimProfile],
|
|
identity: &crate::modem_manager::SimIdentity,
|
|
) {
|
|
let current_index = profiles
|
|
.iter()
|
|
.position(|profile| !identity.iccid.is_empty() && profile.iccid == identity.iccid)
|
|
.or_else(|| profiles.iter().position(esim_profile_is_active));
|
|
|
|
let Some(profile) = current_index.and_then(|index| profiles.get_mut(index)) else {
|
|
return;
|
|
};
|
|
|
|
if esim_profile_state_is_unknown(&profile.state)
|
|
|| !identity.iccid.is_empty() && profile.iccid == identity.iccid
|
|
{
|
|
profile.state = "enabled".to_string();
|
|
}
|
|
if profile.imsi.is_none() && !identity.imsi.is_empty() {
|
|
profile.imsi = Some(identity.imsi.clone());
|
|
}
|
|
let (mcc, mnc) = split_profile_operator_code(&identity.operator_id);
|
|
if profile.mcc.is_none() && !mcc.is_empty() {
|
|
profile.mcc = Some(mcc);
|
|
}
|
|
if profile.mnc.is_none() && !mnc.is_empty() {
|
|
profile.mnc = Some(mnc);
|
|
}
|
|
|
|
if !identity.iccid.is_empty() {
|
|
for item in profiles {
|
|
if item.iccid != identity.iccid && esim_profile_state_is_unknown(&item.state) {
|
|
item.state = "disabled".to_string();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn profile_cache_value(value: &str) -> Option<String> {
|
|
let value = value.trim();
|
|
(!value.is_empty()).then(|| value.to_string())
|
|
}
|
|
|
|
fn optional_profile_cache_value(value: &Option<String>) -> Option<String> {
|
|
value.as_deref().and_then(profile_cache_value)
|
|
}
|
|
|
|
fn profile_cache_entry(profile: &EsimProfile) -> EsimProfileCacheEntry {
|
|
EsimProfileCacheEntry {
|
|
iccid: profile.iccid.trim().to_string(),
|
|
name: profile_cache_value(&profile.name),
|
|
provider: profile_cache_value(&profile.provider),
|
|
state: profile_cache_value(&profile.state),
|
|
profile_class: profile_cache_value(&profile.profile_class),
|
|
imsi: optional_profile_cache_value(&profile.imsi),
|
|
msisdn: optional_profile_cache_value(&profile.msisdn),
|
|
smsc: optional_profile_cache_value(&profile.smsc),
|
|
smdp: optional_profile_cache_value(&profile.smdp),
|
|
matching_id: optional_profile_cache_value(&profile.matching_id),
|
|
isdp_aid: optional_profile_cache_value(&profile.isdp_aid),
|
|
mcc: optional_profile_cache_value(&profile.mcc),
|
|
mnc: optional_profile_cache_value(&profile.mnc),
|
|
disable_allowed: profile.disable_allowed,
|
|
delete_allowed: profile.delete_allowed,
|
|
updated_at: String::new(),
|
|
}
|
|
}
|
|
|
|
fn fill_cached_string(target: &mut String, cached: Option<String>) {
|
|
if target.trim().is_empty() {
|
|
if let Some(value) = cached.and_then(|item| profile_cache_value(&item)) {
|
|
*target = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn fill_cached_option(target: &mut Option<String>, cached: Option<String>) {
|
|
if target.as_deref().unwrap_or("").trim().is_empty() {
|
|
if let Some(value) = cached.and_then(|item| profile_cache_value(&item)) {
|
|
*target = Some(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn hydrate_profile_from_cache(db: &Database, profile: &mut EsimProfile) {
|
|
let cache = match db.get_esim_profile_cache(&profile.iccid) {
|
|
Ok(Some(cache)) => cache,
|
|
Ok(None) => return,
|
|
Err(err) => {
|
|
warn!(iccid = %profile.iccid, error = %err, "Failed to read eSIM profile cache");
|
|
return;
|
|
}
|
|
};
|
|
|
|
fill_cached_string(&mut profile.name, cache.name);
|
|
fill_cached_string(&mut profile.provider, cache.provider);
|
|
fill_cached_string(&mut profile.state, cache.state);
|
|
fill_cached_string(&mut profile.profile_class, cache.profile_class);
|
|
fill_cached_option(&mut profile.imsi, cache.imsi);
|
|
fill_cached_option(&mut profile.msisdn, cache.msisdn);
|
|
fill_cached_option(&mut profile.smsc, cache.smsc);
|
|
fill_cached_option(&mut profile.smdp, cache.smdp);
|
|
fill_cached_option(&mut profile.matching_id, cache.matching_id);
|
|
fill_cached_option(&mut profile.isdp_aid, cache.isdp_aid);
|
|
fill_cached_option(&mut profile.mcc, cache.mcc);
|
|
fill_cached_option(&mut profile.mnc, cache.mnc);
|
|
}
|
|
|
|
fn hydrate_profiles_from_cache(db: &Database, profiles: &mut [EsimProfile]) {
|
|
for profile in profiles {
|
|
hydrate_profile_from_cache(db, profile);
|
|
}
|
|
}
|
|
|
|
fn cache_esim_profiles(db: &Database, profiles: &[EsimProfile]) {
|
|
for profile in profiles {
|
|
if let Err(err) = db.upsert_esim_profile_cache(&profile_cache_entry(profile)) {
|
|
warn!(iccid = %profile.iccid, error = %err, "Failed to write eSIM profile cache");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn profile_from_cache_entry(entry: EsimProfileCacheEntry) -> EsimProfile {
|
|
EsimProfile {
|
|
iccid: entry.iccid,
|
|
name: entry.name.unwrap_or_default(),
|
|
provider: entry.provider.unwrap_or_default(),
|
|
state: entry.state.unwrap_or_else(|| "unknown".to_string()),
|
|
profile_class: entry.profile_class.unwrap_or_default(),
|
|
imsi: entry.imsi,
|
|
msisdn: entry.msisdn,
|
|
smsc: entry.smsc,
|
|
smdp: entry.smdp,
|
|
matching_id: entry.matching_id,
|
|
isdp_aid: entry.isdp_aid,
|
|
mcc: entry.mcc,
|
|
mnc: entry.mnc,
|
|
disable_allowed: entry.disable_allowed.or(Some(true)),
|
|
delete_allowed: entry.delete_allowed.or(Some(true)),
|
|
updated_at: Some(entry.updated_at.clone()),
|
|
raw: json!({
|
|
"source": "cache",
|
|
"updated_at": entry.updated_at,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn cached_profiles_requested(query: &std::collections::HashMap<String, String>) -> bool {
|
|
query
|
|
.get("cached")
|
|
.map(|value| {
|
|
matches!(
|
|
value.trim().to_ascii_lowercase().as_str(),
|
|
"1" | "true" | "yes"
|
|
)
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
// ============ 工作模式 / eSIM ============
|
|
|
|
fn live_refresh_requested(query: &std::collections::HashMap<String, String>) -> bool {
|
|
query
|
|
.get("live")
|
|
.map(|value| {
|
|
matches!(
|
|
value.trim().to_ascii_lowercase().as_str(),
|
|
"1" | "true" | "yes"
|
|
)
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn euicc_cache_key(info: &EsimEuiccInfo) -> String {
|
|
let eid = info.eid.trim();
|
|
if eid.is_empty() {
|
|
"default".to_string()
|
|
} else {
|
|
format!("eid:{eid}")
|
|
}
|
|
}
|
|
|
|
fn euicc_cache_entry(info: &EsimEuiccInfo) -> EsimEuiccCacheEntry {
|
|
EsimEuiccCacheEntry {
|
|
cache_key: euicc_cache_key(info),
|
|
eid: info.eid.clone(),
|
|
status: info.status.clone(),
|
|
manufacturer: info.manufacturer.clone(),
|
|
memory_total_kb: info.memory_total_kb,
|
|
memory_available_kb: info.memory_available_kb,
|
|
memory_total_customizable: info.memory_total_customizable,
|
|
raw: info.raw.to_string(),
|
|
updated_at: info.updated_at.clone().unwrap_or_default(),
|
|
}
|
|
}
|
|
|
|
fn euicc_from_cache_entry(entry: EsimEuiccCacheEntry) -> EsimEuiccInfo {
|
|
let mut raw: serde_json::Value = serde_json::from_str(&entry.raw).unwrap_or_else(|_| json!({}));
|
|
if let Some(object) = raw.as_object_mut() {
|
|
object.insert("source".to_string(), json!("cache"));
|
|
object.insert("updated_at".to_string(), json!(entry.updated_at.clone()));
|
|
} else {
|
|
raw = json!({
|
|
"source": "cache",
|
|
"updated_at": entry.updated_at,
|
|
});
|
|
}
|
|
|
|
EsimEuiccInfo {
|
|
eid: entry.eid,
|
|
status: entry.status,
|
|
manufacturer: entry.manufacturer,
|
|
memory_total_kb: entry.memory_total_kb,
|
|
memory_available_kb: entry.memory_available_kb,
|
|
memory_total_customizable: entry.memory_total_customizable,
|
|
updated_at: Some(entry.updated_at),
|
|
raw,
|
|
}
|
|
}
|
|
|
|
/// GET /api/work-mode
|
|
pub async fn get_work_mode_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
let mode = app.config_manager.get_work_mode();
|
|
let worker_running = app.esim_supervisor.worker_running().await;
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Success",
|
|
WorkModeResponse {
|
|
mode,
|
|
worker_running,
|
|
},
|
|
)),
|
|
)
|
|
}
|
|
|
|
/// POST /api/work-mode
|
|
pub async fn set_work_mode_handler(
|
|
State(app): State<AppState>,
|
|
Json(payload): Json<WorkModeRequest>,
|
|
) -> impl IntoResponse {
|
|
if !payload.confirm {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(ApiResponse::<WorkModeResponse>::error(
|
|
"Changing work mode requires confirm=true",
|
|
)),
|
|
);
|
|
}
|
|
|
|
let previous_mode = app.config_manager.get_work_mode();
|
|
match app.esim_supervisor.switch_mode(payload.mode).await {
|
|
Ok(data) => {
|
|
if previous_mode != data.mode {
|
|
app.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::ESIM_WORK_MODE_CHANGED,
|
|
system_event_severity::INFO,
|
|
system_event_status::CHANGED,
|
|
"work_mode",
|
|
format!("工作模式从 {} 切换为 {}", previous_mode, data.mode),
|
|
)
|
|
.await;
|
|
}
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Work mode updated", data)),
|
|
)
|
|
}
|
|
Err(err) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<WorkModeResponse>::error(err)),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/esim/lpac/status
|
|
pub async fn get_esim_lpac_status_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
match app.esim_supervisor.get_lpac_status().await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
),
|
|
Err(err) => esim_error_response::<EsimLpacStatusResponse>(err),
|
|
}
|
|
}
|
|
|
|
/// POST /api/esim/lpac/repair
|
|
pub async fn repair_esim_lpac_handler(
|
|
State(app): State<AppState>,
|
|
Json(payload): Json<EsimLpacRepairRequest>,
|
|
) -> impl IntoResponse {
|
|
match app.esim_supervisor.repair_lpac(payload).await {
|
|
Ok(data) => {
|
|
app.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::ESIM_LPAC_REPAIR_SUCCEEDED,
|
|
system_event_severity::INFO,
|
|
system_event_status::SUCCEEDED,
|
|
"lpac",
|
|
"lpac 修复成功",
|
|
)
|
|
.await;
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("lpac repaired", data)),
|
|
)
|
|
}
|
|
Err(err) => {
|
|
let message = err.message();
|
|
app.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::ESIM_LPAC_REPAIR_FAILED,
|
|
system_event_severity::WARNING,
|
|
system_event_status::FAILED,
|
|
"lpac",
|
|
format!("lpac 修复失败: {message}"),
|
|
)
|
|
.await;
|
|
esim_error_response::<EsimLpacRepairResponse>(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// GET /api/esim/config
|
|
pub async fn get_esim_config_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
let esim_config = app.config_manager.get_esim_config();
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", esim_config)),
|
|
)
|
|
}
|
|
|
|
/// POST /api/esim/config
|
|
pub async fn set_esim_config_handler(
|
|
State(app): State<AppState>,
|
|
Json(payload): Json<crate::config::EsimConfig>,
|
|
) -> impl IntoResponse {
|
|
match app.config_manager.set_esim_config(payload) {
|
|
Ok(_) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<()>::success_with_message(
|
|
"eSIM config updated successfully",
|
|
(),
|
|
)),
|
|
),
|
|
Err(err) => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(ApiResponse::<()>::error(err)),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/esim/euicc
|
|
pub async fn get_esim_euicc_handler(
|
|
State(app): State<AppState>,
|
|
Query(query): Query<std::collections::HashMap<String, String>>,
|
|
) -> impl IntoResponse {
|
|
if !live_refresh_requested(&query) {
|
|
match app.database.latest_esim_euicc_cache() {
|
|
Ok(Some(entry)) => {
|
|
return (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Cached eUICC",
|
|
euicc_from_cache_entry(entry),
|
|
)),
|
|
);
|
|
}
|
|
Ok(None) => {}
|
|
Err(err) => warn!(error = %err, "Failed to read eUICC cache"),
|
|
}
|
|
}
|
|
|
|
match app.esim_supervisor.get_euicc_info().await {
|
|
Ok(mut data) => {
|
|
data.updated_at = Some(chrono::Utc::now().to_rfc3339());
|
|
if let Err(err) = app
|
|
.database
|
|
.upsert_esim_euicc_cache(&euicc_cache_entry(&data))
|
|
{
|
|
warn!(error = %err, "Failed to write eUICC cache");
|
|
}
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
)
|
|
}
|
|
Err(err) => esim_error_response::<EsimEuiccInfo>(err),
|
|
}
|
|
}
|
|
|
|
/// GET /api/esim/profiles
|
|
pub async fn get_esim_profiles_handler(
|
|
State(app): State<AppState>,
|
|
Query(query): Query<std::collections::HashMap<String, String>>,
|
|
) -> impl IntoResponse {
|
|
if cached_profiles_requested(&query) {
|
|
return match app.database.list_esim_profile_cache() {
|
|
Ok(entries) => {
|
|
let mut profiles: Vec<EsimProfile> =
|
|
entries.into_iter().map(profile_from_cache_entry).collect();
|
|
let needs_identity = profiles
|
|
.iter()
|
|
.any(|profile| esim_profile_state_is_unknown(&profile.state));
|
|
if needs_identity {
|
|
match tokio::time::timeout(
|
|
std::time::Duration::from_millis(ESIM_CACHED_SIM_IDENTITY_TIMEOUT_MS),
|
|
current_sim_identity(&app.dbus_conn),
|
|
)
|
|
.await
|
|
{
|
|
Ok(Some(identity)) => {
|
|
enrich_profiles_with_current_identity(&mut profiles, &identity)
|
|
}
|
|
Ok(None) => {}
|
|
Err(_) => warn!(
|
|
timeout_ms = ESIM_CACHED_SIM_IDENTITY_TIMEOUT_MS,
|
|
"Timed out enriching cached eSIM profiles with current SIM identity"
|
|
),
|
|
}
|
|
}
|
|
sort_esim_profiles_for_display(&mut profiles);
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Cached profiles",
|
|
EsimProfilesResponse { profiles },
|
|
)),
|
|
)
|
|
}
|
|
Err(err) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<EsimProfilesResponse>::error(format!(
|
|
"Failed to read cached profiles: {err}"
|
|
))),
|
|
),
|
|
};
|
|
}
|
|
|
|
match app.esim_supervisor.get_profiles().await {
|
|
Ok(mut data) => {
|
|
hydrate_profiles_from_cache(&app.database, &mut data.profiles);
|
|
match tokio::time::timeout(
|
|
std::time::Duration::from_secs(ESIM_SIM_IDENTITY_TIMEOUT_SECS),
|
|
current_sim_identity(&app.dbus_conn),
|
|
)
|
|
.await
|
|
{
|
|
Ok(Some(identity)) => {
|
|
enrich_profiles_with_current_identity(&mut data.profiles, &identity)
|
|
}
|
|
Ok(None) => {}
|
|
Err(_) => warn!(
|
|
timeout_secs = ESIM_SIM_IDENTITY_TIMEOUT_SECS,
|
|
"Timed out enriching eSIM profiles with current SIM identity"
|
|
),
|
|
}
|
|
cache_esim_profiles(&app.database, &data.profiles);
|
|
sort_esim_profiles_for_display(&mut data.profiles);
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
)
|
|
}
|
|
Err(err) => esim_error_response::<EsimProfilesResponse>(err),
|
|
}
|
|
}
|
|
|
|
/// POST /api/esim/profiles/{iccid}/enable
|
|
pub async fn enable_esim_profile_handler(
|
|
State(app): State<AppState>,
|
|
Path(iccid): Path<String>,
|
|
) -> impl IntoResponse {
|
|
let event_entity = mask_identifier(&iccid);
|
|
|
|
modem_manager::reset_baseband_restart_progress();
|
|
modem_manager::record_restart_step("启用 eSIM Profile", "running", None);
|
|
|
|
let bg_app = app.clone();
|
|
let bg_iccid = iccid.clone();
|
|
let bg_event_entity = event_entity.clone();
|
|
|
|
tokio::spawn(async move {
|
|
let _guard = modem_manager::BasebandRestartRunGuard;
|
|
|
|
match bg_app.esim_supervisor.enable_profile(bg_iccid).await {
|
|
Ok(data) => {
|
|
if esim_command_succeeded(&data) {
|
|
modem_manager::record_restart_step("启用 eSIM Profile", "ok", None);
|
|
let auto_connect_data = !bg_app.data_user_disabled.load(Ordering::SeqCst);
|
|
let allow_roaming = bg_app.config_manager.get_roaming_allowed();
|
|
let apn_config = bg_app.config_manager.get_apn_config();
|
|
match power_cycle_sim_for_profile_switch(
|
|
&bg_app.dbus_conn,
|
|
auto_connect_data,
|
|
allow_roaming,
|
|
Some(apn_config),
|
|
)
|
|
.await
|
|
{
|
|
Ok(_recovery) => {
|
|
if bg_app.sms_resync.request_scan("profile-switch") {
|
|
info!("Requested SMS resync after eSIM profile switch");
|
|
} else {
|
|
warn!("Failed to request SMS resync after eSIM profile switch");
|
|
}
|
|
bg_app
|
|
.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::ESIM_PROFILE_ENABLE_SUCCEEDED,
|
|
system_event_severity::INFO,
|
|
system_event_status::SUCCEEDED,
|
|
bg_event_entity,
|
|
"Profile 启用成功,基带恢复完成",
|
|
)
|
|
.await;
|
|
}
|
|
Err(err) => {
|
|
bg_app
|
|
.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::ESIM_PROFILE_SWITCH_BASEBAND_RECOVERY_FAILED,
|
|
system_event_severity::CRITICAL,
|
|
system_event_status::FAILED,
|
|
bg_event_entity,
|
|
format!("Profile 切换后基带恢复失败: {err}"),
|
|
)
|
|
.await;
|
|
if bg_app
|
|
.sms_resync
|
|
.request_scan("profile-switch-recovery-failed")
|
|
{
|
|
info!("Requested SMS resync after failed eSIM profile recovery");
|
|
} else {
|
|
warn!(
|
|
"Failed to request SMS resync after failed eSIM profile recovery"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
modem_manager::record_restart_step(
|
|
"启用 eSIM Profile",
|
|
"error",
|
|
Some(data.msg.clone()),
|
|
);
|
|
bg_app
|
|
.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::ESIM_PROFILE_ENABLE_FAILED,
|
|
system_event_severity::WARNING,
|
|
system_event_status::FAILED,
|
|
bg_event_entity.clone(),
|
|
format!("Profile 启用失败: {}", data.msg),
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
Err(err) => {
|
|
let message = err.message();
|
|
modem_manager::record_restart_step(
|
|
"启用 eSIM Profile",
|
|
"error",
|
|
Some(message.clone()),
|
|
);
|
|
bg_app
|
|
.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::ESIM_PROFILE_ENABLE_FAILED,
|
|
system_event_severity::WARNING,
|
|
system_event_status::FAILED,
|
|
bg_event_entity.clone(),
|
|
format!("Profile 启用失败: {message}"),
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
});
|
|
|
|
let response = EsimCommandResponse {
|
|
code: 0,
|
|
status: "success".to_string(),
|
|
action: "enable".to_string(),
|
|
msg: "Profile enable task started in background".to_string(),
|
|
data: None,
|
|
};
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Profile enable requested",
|
|
response,
|
|
)),
|
|
)
|
|
}
|
|
|
|
/// POST /api/esim/profiles/{iccid}/rename
|
|
pub async fn rename_esim_profile_handler(
|
|
State(app): State<AppState>,
|
|
Path(iccid): Path<String>,
|
|
Json(payload): Json<EsimRenameRequest>,
|
|
) -> impl IntoResponse {
|
|
let name = payload.name.trim().to_string();
|
|
if name.is_empty() {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(ApiResponse::<EsimCommandResponse>::error(
|
|
"Profile name cannot be empty",
|
|
)),
|
|
);
|
|
}
|
|
match app.esim_supervisor.rename_profile(iccid, name).await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Profile renamed", data)),
|
|
),
|
|
Err(err) => esim_error_response::<EsimCommandResponse>(err),
|
|
}
|
|
}
|
|
|
|
/// DELETE /api/esim/profiles/{iccid}
|
|
pub async fn delete_esim_profile_handler(
|
|
State(app): State<AppState>,
|
|
Path(iccid): Path<String>,
|
|
) -> impl IntoResponse {
|
|
match app.esim_supervisor.delete_profile(iccid.clone()).await {
|
|
Ok(data) => {
|
|
if esim_command_succeeded(&data) {
|
|
if let Err(err) = app.database.delete_esim_profile_cache(&iccid) {
|
|
warn!(iccid = %iccid, error = %err, "Failed to delete eSIM profile cache");
|
|
}
|
|
app.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::ESIM_PROFILE_DELETED,
|
|
system_event_severity::WARNING,
|
|
system_event_status::SUCCEEDED,
|
|
mask_identifier(&iccid),
|
|
"Profile 已删除",
|
|
)
|
|
.await;
|
|
}
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Profile deleted", data)),
|
|
)
|
|
}
|
|
Err(err) => esim_error_response::<EsimCommandResponse>(err),
|
|
}
|
|
}
|
|
|
|
fn find_and_normalize_profile(value: &serde_json::Value) -> Option<EsimProfile> {
|
|
if let Some(obj) = value.as_object() {
|
|
if obj.contains_key("iccid") || obj.contains_key("ICCID") {
|
|
return Some(crate::esim::normalize_profile(value));
|
|
}
|
|
for (_, val) in obj {
|
|
if let Some(p) = find_and_normalize_profile(val) {
|
|
return Some(p);
|
|
}
|
|
}
|
|
} else if let Some(arr) = value.as_array() {
|
|
for val in arr {
|
|
if let Some(p) = find_and_normalize_profile(val) {
|
|
return Some(p);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// POST /api/esim/profiles
|
|
pub async fn download_esim_profile_handler(
|
|
State(app): State<AppState>,
|
|
Json(payload): Json<EsimDownloadRequest>,
|
|
) -> impl IntoResponse {
|
|
let smdp = payload.smdp.trim().to_string();
|
|
let matching_id = payload.matching_id.trim().to_string();
|
|
if smdp.is_empty() || matching_id.is_empty() {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(ApiResponse::<EsimCommandResponse>::error(
|
|
"SM-DP+ server and Matching ID cannot be empty",
|
|
)),
|
|
);
|
|
}
|
|
|
|
// 在写卡前,先异步读取一次卡上的所有 profile ICCID 集合,用于后续新卡判定
|
|
let initial_iccids_opt: Option<std::collections::HashSet<String>> =
|
|
app.esim_supervisor.get_profiles().await.ok().map(|resp| {
|
|
resp.profiles
|
|
.into_iter()
|
|
.map(|p| crate::utils::normalize_iccid(&p.iccid))
|
|
.collect()
|
|
});
|
|
|
|
match app.esim_supervisor.download_profile(payload.clone()).await {
|
|
Ok(data) => {
|
|
if esim_command_succeeded(&data) {
|
|
// Attempt to recursively find the downloaded profile details in lpac's response
|
|
let profile_val = data.data.clone().unwrap_or(serde_json::Value::Null);
|
|
if let Some(mut profile) = find_and_normalize_profile(&profile_val) {
|
|
// Supplement SM-DP+ if not returned
|
|
if profile.smdp.as_deref().unwrap_or("").trim().is_empty() {
|
|
profile.smdp = Some(smdp.clone());
|
|
}
|
|
if profile
|
|
.matching_id
|
|
.as_deref()
|
|
.unwrap_or("")
|
|
.trim()
|
|
.is_empty()
|
|
{
|
|
profile.matching_id = Some(matching_id.clone());
|
|
}
|
|
|
|
let entry = EsimProfileCacheEntry {
|
|
iccid: profile.iccid.clone(),
|
|
name: Some(profile.name.clone()),
|
|
provider: Some(profile.provider.clone()),
|
|
state: Some(profile.state.clone()),
|
|
profile_class: Some(profile.profile_class.clone()),
|
|
imsi: profile.imsi.clone(),
|
|
msisdn: profile.msisdn.clone(),
|
|
smsc: profile.smsc.clone(),
|
|
smdp: profile.smdp.clone(),
|
|
matching_id: profile.matching_id.clone(),
|
|
isdp_aid: profile.isdp_aid.clone(),
|
|
mcc: profile.mcc.clone(),
|
|
mnc: profile.mnc.clone(),
|
|
disable_allowed: profile.disable_allowed,
|
|
delete_allowed: profile.delete_allowed,
|
|
updated_at: chrono::Utc::now().to_rfc3339(),
|
|
};
|
|
|
|
if let Err(err) = app.database.upsert_esim_profile_cache(&entry) {
|
|
warn!(iccid = %entry.iccid, error = %err, "Failed to cache downloaded eSIM profile to database");
|
|
}
|
|
|
|
app.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::ESIM_PROFILE_DOWNLOAD_SUCCEEDED,
|
|
system_event_severity::INFO,
|
|
system_event_status::SUCCEEDED,
|
|
mask_identifier(&entry.iccid),
|
|
"Profile 写入并缓存成功",
|
|
)
|
|
.await;
|
|
} else {
|
|
// Fallback if we couldn't parse the profile details from lpac.
|
|
// Query the profiles on the card to identify the new one(s) that lack smdp/matching_id in cache.
|
|
let mut cached_fallback_iccid = None;
|
|
|
|
// 1. 等待 1.5 秒,让 eUICC 卡片状态恢复稳定
|
|
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
|
|
|
// 2. 尝试读取最新列表,最多重试 4 次,每次间隔 1.5 秒
|
|
let mut profiles_resp = None;
|
|
for attempt in 1..=4 {
|
|
match app.esim_supervisor.get_profiles().await {
|
|
Ok(resp) => {
|
|
profiles_resp = Some(resp);
|
|
break;
|
|
}
|
|
Err(err) => {
|
|
warn!(attempt = attempt, error = ?err, "Failed to get profiles during fallback retry");
|
|
if attempt < 4 {
|
|
tokio::time::sleep(std::time::Duration::from_millis(1500))
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(resp) = profiles_resp {
|
|
if let Some(ref init_iccids) = initial_iccids_opt {
|
|
for p in resp.profiles {
|
|
let norm_iccid = crate::utils::normalize_iccid(&p.iccid);
|
|
let is_new_profile = !init_iccids.contains(&norm_iccid);
|
|
|
|
if is_new_profile {
|
|
let needs_cache =
|
|
match app.database.get_esim_profile_cache(&p.iccid) {
|
|
Ok(Some(cached_entry)) => cached_entry
|
|
.smdp
|
|
.as_deref()
|
|
.unwrap_or("")
|
|
.trim()
|
|
.is_empty(),
|
|
_ => true,
|
|
};
|
|
if needs_cache {
|
|
let entry = EsimProfileCacheEntry {
|
|
iccid: p.iccid.clone(),
|
|
name: Some(p.name.clone()),
|
|
provider: Some(p.provider.clone()),
|
|
state: Some(p.state.clone()),
|
|
profile_class: Some(p.profile_class.clone()),
|
|
imsi: p.imsi.clone(),
|
|
msisdn: p.msisdn.clone(),
|
|
smsc: p.smsc.clone(),
|
|
smdp: Some(smdp.clone()),
|
|
matching_id: Some(matching_id.clone()),
|
|
isdp_aid: p.isdp_aid.clone(),
|
|
mcc: p.mcc.clone(),
|
|
mnc: p.mnc.clone(),
|
|
disable_allowed: p.disable_allowed,
|
|
delete_allowed: p.delete_allowed,
|
|
updated_at: chrono::Utc::now().to_rfc3339(),
|
|
};
|
|
if let Err(err) =
|
|
app.database.upsert_esim_profile_cache(&entry)
|
|
{
|
|
warn!(iccid = %entry.iccid, error = %err, "Failed to cache fallback eSIM profile to database");
|
|
} else {
|
|
cached_fallback_iccid = Some(p.iccid.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
warn!("Initial ICCIDs list was unavailable before writing; fallback difference detection skipped to prevent profile mismatch");
|
|
}
|
|
} else {
|
|
error!("Failed to fetch profiles list after writing even with retries; fallback profile caching cannot proceed");
|
|
}
|
|
|
|
let event_entity = cached_fallback_iccid
|
|
.as_ref()
|
|
.map(|iccid| mask_identifier(iccid))
|
|
.unwrap_or_else(|| "esim".to_string());
|
|
|
|
app.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::ESIM_PROFILE_DOWNLOAD_SUCCEEDED,
|
|
system_event_severity::INFO,
|
|
system_event_status::SUCCEEDED,
|
|
event_entity,
|
|
"Profile 写入成功,已通过列表扫描更新缓存",
|
|
)
|
|
.await;
|
|
}
|
|
} else {
|
|
let msg = data.msg.clone();
|
|
let is_refused = msg.contains("MatchingID is refused")
|
|
|| msg.contains("es9p_initiate_authentication")
|
|
|| msg.contains("es10b_load_bound_profile_package")
|
|
|| data
|
|
.data
|
|
.as_ref()
|
|
.map(|v| {
|
|
let s = v.to_string();
|
|
s.contains("MatchingID is refused")
|
|
|| s.contains("es9p_initiate_authentication")
|
|
|| s.contains("es10b_load_bound_profile_package")
|
|
})
|
|
.unwrap_or(false);
|
|
|
|
if is_refused {
|
|
info!("MatchingID is refused, attempting to bind matching info to the profile if it exists");
|
|
let mut cached_fallback_iccid = None;
|
|
if let Ok(profiles_resp) = app.esim_supervisor.get_profiles().await {
|
|
for p in profiles_resp.profiles {
|
|
let needs_cache = match app.database.get_esim_profile_cache(&p.iccid) {
|
|
Ok(Some(cached_entry)) => {
|
|
cached_entry.smdp.as_deref().unwrap_or("").trim().is_empty()
|
|
}
|
|
_ => true,
|
|
};
|
|
if needs_cache {
|
|
let entry = EsimProfileCacheEntry {
|
|
iccid: p.iccid.clone(),
|
|
name: Some(p.name.clone()),
|
|
provider: Some(p.provider.clone()),
|
|
state: Some(p.state.clone()),
|
|
profile_class: Some(p.profile_class.clone()),
|
|
imsi: p.imsi.clone(),
|
|
msisdn: p.msisdn.clone(),
|
|
smsc: p.smsc.clone(),
|
|
smdp: Some(smdp.clone()),
|
|
matching_id: Some(matching_id.clone()),
|
|
isdp_aid: p.isdp_aid.clone(),
|
|
mcc: p.mcc.clone(),
|
|
mnc: p.mnc.clone(),
|
|
disable_allowed: p.disable_allowed,
|
|
delete_allowed: p.delete_allowed,
|
|
updated_at: chrono::Utc::now().to_rfc3339(),
|
|
};
|
|
if let Ok(_) = app.database.upsert_esim_profile_cache(&entry) {
|
|
cached_fallback_iccid = Some(p.iccid.clone());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let Some(ref iccid) = cached_fallback_iccid {
|
|
app.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::ESIM_PROFILE_DOWNLOAD_SUCCEEDED,
|
|
system_event_severity::INFO,
|
|
system_event_status::SUCCEEDED,
|
|
mask_identifier(iccid),
|
|
"Profile 已被使用,成功将 Matching ID 绑定至对应卡片",
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
app.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::ESIM_PROFILE_DOWNLOAD_FAILED,
|
|
system_event_severity::WARNING,
|
|
system_event_status::FAILED,
|
|
"esim",
|
|
format!("Profile 写入失败: {}", data.msg),
|
|
)
|
|
.await;
|
|
}
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Profile downloaded",
|
|
data,
|
|
)),
|
|
)
|
|
}
|
|
Err(err) => {
|
|
let message = err.message();
|
|
app.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::ESIM_PROFILE_DOWNLOAD_FAILED,
|
|
system_event_severity::WARNING,
|
|
system_event_status::FAILED,
|
|
"esim",
|
|
format!("Profile 写入失败: {message}"),
|
|
)
|
|
.await;
|
|
esim_error_response::<EsimCommandResponse>(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============ 设备信息 ============
|
|
|
|
/// GET /api/device
|
|
pub async fn get_device_info(State(conn): State<Arc<Connection>>) -> impl IntoResponse {
|
|
match get_device_info_data(&conn).await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<DeviceInfoResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
// ============ SIM 卡 ============
|
|
|
|
fn sim_identity_from_response(data: &SimInfoResponse) -> modem_manager::SimIdentity {
|
|
let mut operator_id = data.registered_operator_code.trim().to_string();
|
|
if operator_id.is_empty() && !data.mcc.is_empty() && !data.mnc.is_empty() {
|
|
operator_id = format!("{}{}", data.mcc, data.mnc);
|
|
}
|
|
modem_manager::SimIdentity {
|
|
iccid: data.iccid.clone(),
|
|
imsi: data.imsi.clone(),
|
|
operator_id,
|
|
}
|
|
}
|
|
|
|
fn maybe_refresh_sim_details_after_fast_response(
|
|
conn: &Arc<Connection>,
|
|
db: &Arc<Database>,
|
|
data: &SimInfoResponse,
|
|
) {
|
|
if !data.present {
|
|
return;
|
|
}
|
|
let identity = sim_identity_from_response(data);
|
|
if !sim_details_cache_missing(db, &identity) {
|
|
return;
|
|
}
|
|
let conn_bg = Arc::clone(conn);
|
|
let db_bg = Arc::clone(db);
|
|
tokio::spawn(async move {
|
|
refresh_sim_details_background(&conn_bg, &db_bg, false).await;
|
|
});
|
|
}
|
|
|
|
/// GET /api/sim
|
|
pub async fn get_sim_info(
|
|
State((conn, db)): State<(Arc<Connection>, Arc<Database>)>,
|
|
) -> impl IntoResponse {
|
|
match get_sim_info_data_with_cache(&conn, Some(&db)).await {
|
|
Ok(data) => {
|
|
maybe_refresh_sim_details_after_fast_response(&conn, &db, &data);
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
)
|
|
}
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<SimInfoResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/sim/details/refresh
|
|
pub async fn refresh_sim_details_handler(
|
|
State((conn, db)): State<(Arc<Connection>, Arc<Database>)>,
|
|
) -> impl IntoResponse {
|
|
let conn_bg = Arc::clone(&conn);
|
|
let db_bg = Arc::clone(&db);
|
|
tokio::spawn(async move {
|
|
refresh_sim_details_background(&conn_bg, &db_bg, true).await;
|
|
});
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"SIM details refresh started",
|
|
json!({}),
|
|
)),
|
|
)
|
|
}
|
|
|
|
/// POST /api/sim/cache
|
|
pub async fn update_sim_cache_handler(
|
|
State(app): State<AppState>,
|
|
Json(payload): Json<UpdateSimCacheRequest>,
|
|
) -> impl IntoResponse {
|
|
let identity = match tokio::time::timeout(
|
|
std::time::Duration::from_secs(ESIM_SIM_IDENTITY_TIMEOUT_SECS),
|
|
current_sim_identity(&app.dbus_conn),
|
|
)
|
|
.await
|
|
{
|
|
Ok(Some(identity)) => identity,
|
|
_ => {
|
|
return (
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
Json(ApiResponse::<serde_json::Value>::error(
|
|
"Unable to get current SIM identity",
|
|
)),
|
|
);
|
|
}
|
|
};
|
|
|
|
if let Some(sms_center) = &payload.sms_center {
|
|
crate::modem_manager::cache_smsc_for_identity(
|
|
&app.database,
|
|
&identity,
|
|
sms_center,
|
|
"manual",
|
|
);
|
|
}
|
|
|
|
if let Some(phone_number) = &payload.phone_number {
|
|
crate::modem_manager::cache_own_numbers_for_identity(
|
|
&app.database,
|
|
&identity,
|
|
&[phone_number.clone()],
|
|
"manual",
|
|
);
|
|
}
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"SIM cache updated",
|
|
json!({}),
|
|
)),
|
|
)
|
|
}
|
|
|
|
// ============ 网络信息 ============
|
|
|
|
/// GET /api/network
|
|
pub async fn get_network_info(State(conn): State<Arc<Connection>>) -> impl IntoResponse {
|
|
match get_network_info_data(&conn).await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<NetworkInfoResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/cells
|
|
pub async fn get_cells(State(conn): State<Arc<Connection>>) -> impl IntoResponse {
|
|
match get_cells_data(&conn).await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<CellsResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/cell-monitor/start
|
|
pub async fn start_cell_monitor_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
if app.cell_monitoring_active.swap(true, Ordering::SeqCst) {
|
|
return (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Cell monitor already active",
|
|
json!({}),
|
|
)),
|
|
);
|
|
}
|
|
|
|
match start_cell_monitoring().await {
|
|
Ok(()) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Cell monitor activated",
|
|
json!({}),
|
|
)),
|
|
),
|
|
Err(e) => {
|
|
app.cell_monitoring_active.store(false, Ordering::SeqCst);
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<serde_json::Value>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// POST /api/cell-monitor/stop
|
|
pub async fn stop_cell_monitor_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
if !app.cell_monitoring_active.swap(false, Ordering::SeqCst) {
|
|
return (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Cell monitor already inactive",
|
|
json!({}),
|
|
)),
|
|
);
|
|
}
|
|
|
|
match stop_cell_monitoring().await {
|
|
Ok(()) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Cell monitor deactivated",
|
|
json!({}),
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<serde_json::Value>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/radio-mode
|
|
pub async fn get_radio_mode_handler(State(conn): State<Arc<Connection>>) -> impl IntoResponse {
|
|
match get_radio_mode(&conn).await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<RadioModeResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/radio-mode
|
|
pub async fn set_radio_mode_handler(
|
|
State(conn): State<Arc<Connection>>,
|
|
Json(payload): Json<RadioModeRequest>,
|
|
) -> impl IntoResponse {
|
|
match set_radio_mode(&conn, payload.mode).await {
|
|
Ok(()) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Radio mode updated",
|
|
json!({}),
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<serde_json::Value>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/band-lock
|
|
pub async fn get_band_lock_handler(State(conn): State<Arc<Connection>>) -> impl IntoResponse {
|
|
match get_band_lock_status(&conn).await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<BandLockStatus>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/band-lock
|
|
pub async fn set_band_lock_handler(
|
|
State(conn): State<Arc<Connection>>,
|
|
Json(payload): Json<BandLockRequest>,
|
|
) -> impl IntoResponse {
|
|
match set_band_lock(&conn, &payload).await {
|
|
Ok(()) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Band selection updated",
|
|
json!({}),
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<serde_json::Value>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/location/cell-info
|
|
pub async fn get_cell_location_handler(State(conn): State<Arc<Connection>>) -> impl IntoResponse {
|
|
match get_cell_location(&conn).await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<CellLocationResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/network/operators
|
|
pub async fn get_network_operators(State(conn): State<Arc<Connection>>) -> impl IntoResponse {
|
|
match get_operators_list(&conn).await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<OperatorListResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/network/operators/scan
|
|
pub async fn scan_network_operators(State(conn): State<Arc<Connection>>) -> impl IntoResponse {
|
|
match scan_operators(&conn).await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<OperatorListResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/network/register-manual
|
|
pub async fn register_network_manual(
|
|
State(conn): State<Arc<Connection>>,
|
|
Json(payload): Json<ManualRegisterRequest>,
|
|
) -> impl IntoResponse {
|
|
match register_operator_manual(&conn, &payload.mccmnc).await {
|
|
Ok(()) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Registration started",
|
|
json!({}),
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<serde_json::Value>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/network/register-auto
|
|
pub async fn register_network_auto(State(conn): State<Arc<Connection>>) -> impl IntoResponse {
|
|
match register_operator_auto(&conn).await {
|
|
Ok(()) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Auto registration started",
|
|
json!({}),
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<serde_json::Value>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/apn
|
|
pub async fn get_apn_list_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
let apn_config = app.config_manager.get_apn_config();
|
|
match list_apn_contexts(&app.dbus_conn, Some(&apn_config)).await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<ApnListResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/apn
|
|
pub async fn set_apn_handler(
|
|
State(app): State<AppState>,
|
|
Json(payload): Json<SetApnRequest>,
|
|
) -> impl IntoResponse {
|
|
let mut apn_config = app.config_manager.get_apn_config();
|
|
if let Some(apn) = &payload.apn {
|
|
apn_config.apn = apn.trim().to_string();
|
|
}
|
|
if let Some(protocol) = &payload.protocol {
|
|
apn_config.protocol = protocol.trim().to_string();
|
|
}
|
|
if let Some(username) = &payload.username {
|
|
apn_config.username = username.trim().to_string();
|
|
}
|
|
if let Some(password) = &payload.password {
|
|
apn_config.password = password.clone();
|
|
}
|
|
if let Some(auth_method) = &payload.auth_method {
|
|
apn_config.auth_method = auth_method.trim().to_string();
|
|
}
|
|
if apn_config.protocol.trim().is_empty() {
|
|
apn_config.protocol = ApnConfig::default().protocol;
|
|
}
|
|
if apn_config.auth_method.trim().is_empty() {
|
|
apn_config.auth_method = ApnConfig::default().auth_method;
|
|
}
|
|
|
|
if let Err(err) = app.config_manager.set_apn_config(apn_config) {
|
|
return (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<serde_json::Value>::error(format!(
|
|
"Failed to save APN config: {}",
|
|
err
|
|
))),
|
|
);
|
|
}
|
|
|
|
let context_path = payload.context_path.trim();
|
|
if context_path.is_empty() || context_path.ends_with("/bearer/default") {
|
|
return (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"APN config saved",
|
|
json!({}),
|
|
)),
|
|
);
|
|
}
|
|
|
|
match set_apn_on_bearer(&app.dbus_conn, &payload).await {
|
|
Ok(()) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("APN updated", json!({}))),
|
|
),
|
|
Err(e) => {
|
|
warn!(error = %e, "APN config saved but bearer update failed");
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"APN config saved",
|
|
json!({ "bearer_update_error": e.to_string() }),
|
|
)),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// GET /api/cell-lock
|
|
pub async fn get_cell_lock_status_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
let store = app.cell_lock.lock().await;
|
|
let data = store.status();
|
|
drop(store);
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
)
|
|
}
|
|
|
|
/// POST /api/cell-lock
|
|
pub async fn set_cell_lock_handler(
|
|
State(app): State<AppState>,
|
|
Json(payload): Json<CellLockRequest>,
|
|
) -> impl IntoResponse {
|
|
let mut store = app.cell_lock.lock().await;
|
|
match store.apply(&payload) {
|
|
Ok(()) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"OK",
|
|
CellLockResult { success: true },
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<CellLockResult>::error(e)),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/cell-lock/unlock-all
|
|
pub async fn unlock_all_cells_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
let mut store = app.cell_lock.lock().await;
|
|
store.unlock_all();
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Unlocked",
|
|
CellLockResult { success: true },
|
|
)),
|
|
)
|
|
}
|
|
|
|
/// GET /api/network/interfaces
|
|
pub async fn get_network_interfaces_info(
|
|
State(dbus_conn): State<Arc<Connection>>,
|
|
) -> impl IntoResponse {
|
|
match read_network_interfaces(Some(&dbus_conn)).await {
|
|
Ok(interfaces) => {
|
|
let total_count = interfaces.len();
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Success",
|
|
NetworkInterfacesResponse {
|
|
interfaces,
|
|
total_count,
|
|
},
|
|
)),
|
|
)
|
|
}
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<NetworkInterfacesResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/network/connection-addresses
|
|
pub async fn get_network_connection_addresses(
|
|
State(dbus_conn): State<Arc<Connection>>,
|
|
) -> impl IntoResponse {
|
|
match read_network_interfaces(Some(&dbus_conn)).await {
|
|
Ok(interfaces) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Success",
|
|
connection_addresses_from_interfaces(&interfaces),
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<ConnectionAddressesResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/device-network/ddns/config
|
|
pub async fn get_device_ddns_config_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
let config = app.config_manager.get_ddns_config();
|
|
let access_secret_set = !config.access_secret.trim().is_empty();
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Success",
|
|
ddns_config_response(config, access_secret_set),
|
|
)),
|
|
)
|
|
}
|
|
|
|
/// POST /api/device-network/ddns/config
|
|
pub async fn set_device_ddns_config_handler(
|
|
State(app): State<AppState>,
|
|
Json(mut payload): Json<crate::config::DdnsConfig>,
|
|
) -> impl IntoResponse {
|
|
let current = app.config_manager.get_ddns_config();
|
|
if is_masked_secret(&payload.access_id) {
|
|
payload.access_id = current.access_id;
|
|
}
|
|
if payload.access_secret.trim().is_empty() {
|
|
payload.access_secret = current.access_secret;
|
|
} else if is_masked_secret(&payload.access_secret) {
|
|
payload.access_secret = current.access_secret;
|
|
}
|
|
if payload.interval_seconds == 0 {
|
|
payload.interval_seconds = 300;
|
|
}
|
|
if payload.ttl == 0 {
|
|
payload.ttl = 600;
|
|
}
|
|
|
|
match app.config_manager.set_ddns_config(payload.clone()) {
|
|
Ok(()) => {
|
|
let access_secret_set = !payload.access_secret.trim().is_empty();
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"DDNS config updated",
|
|
ddns_config_response(payload, access_secret_set),
|
|
)),
|
|
)
|
|
}
|
|
Err(err) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<serde_json::Value>::error(format!(
|
|
"Failed: {}",
|
|
err
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
fn ddns_config_response(
|
|
mut config: crate::config::DdnsConfig,
|
|
access_secret_set: bool,
|
|
) -> serde_json::Value {
|
|
config.access_id = mask_secret(&config.access_id);
|
|
config.access_secret = mask_secret(&config.access_secret);
|
|
let mut value = serde_json::to_value(config).unwrap_or_else(|_| json!({}));
|
|
if let Some(object) = value.as_object_mut() {
|
|
object.insert("access_secret_set".to_string(), json!(access_secret_set));
|
|
}
|
|
value
|
|
}
|
|
|
|
fn mask_secret(value: &str) -> String {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() {
|
|
return String::new();
|
|
}
|
|
let prefix: String = trimmed.chars().take(3).collect();
|
|
format!("{prefix}******")
|
|
}
|
|
|
|
fn is_masked_secret(value: &str) -> bool {
|
|
value.contains('*')
|
|
}
|
|
|
|
/// GET /api/device-network/ddns/status
|
|
pub async fn get_device_ddns_status_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
let config = app.config_manager.get_ddns_config();
|
|
let status = app.ddns_manager.status(&config).await;
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", status)),
|
|
)
|
|
}
|
|
|
|
/// POST /api/device-network/ddns/sync
|
|
pub async fn sync_device_ddns_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
match app
|
|
.ddns_manager
|
|
.sync_now(app.config_manager.clone(), app.notification_sender.clone())
|
|
.await
|
|
{
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"DDNS sync completed",
|
|
data,
|
|
)),
|
|
),
|
|
Err(err) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<DdnsSyncResponse>::error(format!(
|
|
"Failed: {}",
|
|
err
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/device-network/ddns/logs
|
|
pub async fn get_device_ddns_logs_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
let logs = app.ddns_manager.logs().await;
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", logs)),
|
|
)
|
|
}
|
|
|
|
/// POST /api/device-network/ddns/logs/clear
|
|
pub async fn clear_device_ddns_logs_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
app.ddns_manager.clear_logs().await;
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"DDNS logs cleared",
|
|
json!({}),
|
|
)),
|
|
)
|
|
}
|
|
|
|
/// GET /api/device-network/wlan/status
|
|
pub async fn get_device_wlan_status_handler() -> impl IntoResponse {
|
|
match crate::device_network::wlan_status().await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
),
|
|
Err(err) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<WlanStatusResponse>::error(format!(
|
|
"Failed: {}",
|
|
err
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/device-network/wlan/enabled
|
|
pub async fn set_device_wlan_enabled_handler(
|
|
Json(payload): Json<WlanEnabledRequest>,
|
|
) -> impl IntoResponse {
|
|
match crate::device_network::wlan_set_enabled(payload).await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"WLAN state updated",
|
|
data,
|
|
)),
|
|
),
|
|
Err(err) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<WlanStatusResponse>::error(format!(
|
|
"Failed: {}",
|
|
err
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/device-network/wlan/scan
|
|
pub async fn scan_device_wlan_handler() -> impl IntoResponse {
|
|
match crate::device_network::wlan_scan().await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
),
|
|
Err(err) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<WlanScanResponse>::error(format!(
|
|
"Failed: {}",
|
|
err
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/device-network/wlan/profiles
|
|
pub async fn get_device_wlan_profiles_handler() -> impl IntoResponse {
|
|
match crate::device_network::wlan_profiles().await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
),
|
|
Err(err) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<WlanProfilesResponse>::error(format!(
|
|
"Failed: {}",
|
|
err
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/device-network/wlan/forget
|
|
pub async fn forget_device_wlan_handler(
|
|
Json(payload): Json<WlanForgetRequest>,
|
|
) -> impl IntoResponse {
|
|
match crate::device_network::wlan_forget(payload).await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"WLAN profile forgotten",
|
|
data,
|
|
)),
|
|
),
|
|
Err(err) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<WlanProfilesResponse>::error(format!(
|
|
"Failed: {}",
|
|
err
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/device-network/wlan/connect
|
|
pub async fn connect_device_wlan_handler(
|
|
State(app): State<AppState>,
|
|
Json(payload): Json<WlanConnectRequest>,
|
|
) -> impl IntoResponse {
|
|
let target_ssid = payload.ssid.clone();
|
|
let previous = crate::device_network::wlan_status().await.ok();
|
|
match crate::device_network::wlan_connect(payload).await {
|
|
Ok(data) => {
|
|
if data.connected {
|
|
app.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::DEVICE_NETWORK_WLAN_CONNECTED,
|
|
system_event_severity::INFO,
|
|
system_event_status::SUCCEEDED,
|
|
data.ssid.clone().unwrap_or_else(|| target_ssid.clone()),
|
|
"WLAN 已连接",
|
|
)
|
|
.await;
|
|
let previous_ssid = previous.and_then(|status| status.ssid);
|
|
if previous_ssid.is_some() && previous_ssid != data.ssid && data.ssid.is_some() {
|
|
app.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::DEVICE_NETWORK_WLAN_SSID_CHANGED,
|
|
system_event_severity::INFO,
|
|
system_event_status::CHANGED,
|
|
data.ssid.clone().unwrap_or_default(),
|
|
"WLAN SSID 已变化",
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("WLAN connected", data)),
|
|
)
|
|
}
|
|
Err(err) => {
|
|
app.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::DEVICE_NETWORK_WLAN_CONNECT_FAILED,
|
|
system_event_severity::WARNING,
|
|
system_event_status::FAILED,
|
|
target_ssid,
|
|
format!("WLAN 连接失败: {err}"),
|
|
)
|
|
.await;
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<WlanStatusResponse>::error(format!(
|
|
"Failed: {}",
|
|
err
|
|
))),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// POST /api/device-network/wlan/disconnect
|
|
pub async fn disconnect_device_wlan_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
let previous = crate::device_network::wlan_status().await.ok();
|
|
match crate::device_network::wlan_disconnect().await {
|
|
Ok(data) => {
|
|
if previous
|
|
.as_ref()
|
|
.map(|status| status.connected)
|
|
.unwrap_or(false)
|
|
&& !data.connected
|
|
{
|
|
app.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::DEVICE_NETWORK_WLAN_DISCONNECTED,
|
|
system_event_severity::INFO,
|
|
system_event_status::CHANGED,
|
|
previous.and_then(|status| status.ssid).unwrap_or_default(),
|
|
"WLAN 已断开",
|
|
)
|
|
.await;
|
|
}
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("WLAN disconnected", data)),
|
|
)
|
|
}
|
|
Err(err) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<WlanStatusResponse>::error(format!(
|
|
"Failed: {}",
|
|
err
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/device-network/wlan/profile
|
|
pub async fn save_device_wlan_profile_handler(
|
|
Json(payload): Json<WlanProfileRequest>,
|
|
) -> impl IntoResponse {
|
|
match crate::device_network::wlan_save_profile(payload).await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"WLAN profile updated",
|
|
data,
|
|
)),
|
|
),
|
|
Err(err) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<WlanStatusResponse>::error(format!(
|
|
"Failed: {}",
|
|
err
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/network/signal-strength
|
|
pub async fn get_signal_strength_handler(State(conn): State<Arc<Connection>>) -> impl IntoResponse {
|
|
match get_signal_strength(&conn).await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<SignalStrengthResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
// ============ 数据连接 ============
|
|
|
|
/// GET /api/data
|
|
pub async fn get_data_status(State(app): State<AppState>) -> impl IntoResponse {
|
|
if app.data_user_disabled.load(Ordering::SeqCst) {
|
|
return (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Success",
|
|
DataConnectionResponse { active: false },
|
|
)),
|
|
);
|
|
}
|
|
|
|
match get_data_connection_status(&app.dbus_conn).await {
|
|
Ok(active) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Success",
|
|
DataConnectionResponse { active },
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<DataConnectionResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/data
|
|
pub async fn set_data_status(
|
|
State(app): State<AppState>,
|
|
Json(payload): Json<DataConnectionRequest>,
|
|
) -> impl IntoResponse {
|
|
let previous_active = !app.data_user_disabled.load(Ordering::SeqCst);
|
|
let allow_roaming = app.config_manager.get_roaming_allowed();
|
|
let apn_config = app.config_manager.get_apn_config();
|
|
match set_data_connection_with_apn(
|
|
&app.dbus_conn,
|
|
payload.active,
|
|
allow_roaming,
|
|
Some(&apn_config),
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
if let Err(err) = app.config_manager.set_data_enabled(payload.active) {
|
|
return (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<DataConnectionResponse>::error(format!(
|
|
"Failed to save data switch state: {}",
|
|
err
|
|
))),
|
|
);
|
|
}
|
|
app.data_user_disabled
|
|
.store(!payload.active, Ordering::SeqCst);
|
|
if previous_active != payload.active {
|
|
app.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::CELLULAR_DATA_ENABLED_CHANGED,
|
|
system_event_severity::INFO,
|
|
system_event_status::CHANGED,
|
|
"cellular_data",
|
|
if payload.active {
|
|
"蜂窝数据开关已开启"
|
|
} else {
|
|
"蜂窝数据开关已关闭"
|
|
},
|
|
)
|
|
.await;
|
|
}
|
|
// 同步 NM autoconnect 状态,防止用户关闭数据后 NM 自动重连
|
|
tokio::spawn(async move {
|
|
if let Ok(profile) = find_nm_modem_connection_pub().await {
|
|
let _ = nm_set_autoconnect_pub(&profile, payload.active).await;
|
|
}
|
|
});
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Data connection updated",
|
|
DataConnectionResponse {
|
|
active: payload.active,
|
|
},
|
|
)),
|
|
)
|
|
}
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<DataConnectionResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
pub async fn restart_baseband_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
let auto_connect_data = !app.data_user_disabled.load(Ordering::SeqCst);
|
|
let allow_roaming = app.config_manager.get_roaming_allowed();
|
|
let apn_config = app.config_manager.get_apn_config();
|
|
match restart_baseband(
|
|
&app.dbus_conn,
|
|
auto_connect_data,
|
|
allow_roaming,
|
|
Some(apn_config),
|
|
)
|
|
.await
|
|
{
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Baseband restarted",
|
|
data,
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<BasebandRestartResponse>::error(format!(
|
|
"重启基带失败:{e}",
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
pub async fn get_baseband_restart_status_handler() -> impl IntoResponse {
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Success",
|
|
get_baseband_restart_progress(),
|
|
)),
|
|
)
|
|
}
|
|
|
|
/// GET /api/roaming
|
|
pub async fn get_roaming_status_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
let roaming_allowed = app.config_manager.get_roaming_allowed();
|
|
match get_is_roaming_mm(&app.dbus_conn).await {
|
|
Ok(is_roaming) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Success",
|
|
RoamingResponse {
|
|
roaming_allowed,
|
|
is_roaming,
|
|
},
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<RoamingResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/roaming
|
|
pub async fn set_roaming_status_handler(
|
|
State(app): State<AppState>,
|
|
Json(payload): Json<RoamingRequest>,
|
|
) -> impl IntoResponse {
|
|
let previous_allowed = app.config_manager.get_roaming_allowed();
|
|
match apply_roaming_policy(&app.dbus_conn, &app.config_manager, payload.allowed).await {
|
|
Ok(_) => {
|
|
let roaming_allowed = app.config_manager.get_roaming_allowed();
|
|
if previous_allowed != roaming_allowed {
|
|
app.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::CELLULAR_ROAMING_ALLOWED_CHANGED,
|
|
system_event_severity::INFO,
|
|
system_event_status::CHANGED,
|
|
"roaming",
|
|
if roaming_allowed {
|
|
"允许漫游已开启"
|
|
} else {
|
|
"允许漫游已关闭"
|
|
},
|
|
)
|
|
.await;
|
|
}
|
|
match get_is_roaming_mm(&app.dbus_conn).await {
|
|
Ok(is_roaming) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Success",
|
|
RoamingResponse {
|
|
roaming_allowed,
|
|
is_roaming,
|
|
},
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<RoamingResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<RoamingResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/airplane-mode
|
|
pub async fn set_airplane_mode_handler(
|
|
State(app): State<AppState>,
|
|
Json(payload): Json<AirplaneModeRequest>,
|
|
) -> impl IntoResponse {
|
|
let previous_enabled = get_airplane_mode(&app.dbus_conn)
|
|
.await
|
|
.ok()
|
|
.map(|status| status.enabled);
|
|
if payload.enabled {
|
|
app.airplane_mode_requested.store(true, Ordering::SeqCst);
|
|
}
|
|
|
|
match set_airplane_mode(&app.dbus_conn, payload.enabled).await {
|
|
Ok(_) => {
|
|
app.airplane_mode_requested
|
|
.store(payload.enabled, Ordering::SeqCst);
|
|
match get_airplane_mode(&app.dbus_conn).await {
|
|
Ok(status) => {
|
|
if previous_enabled != Some(status.enabled) {
|
|
app.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::CELLULAR_AIRPLANE_MODE_CHANGED,
|
|
system_event_severity::INFO,
|
|
system_event_status::CHANGED,
|
|
"airplane_mode",
|
|
if status.enabled {
|
|
"飞行模式已开启"
|
|
} else {
|
|
"飞行模式已关闭"
|
|
},
|
|
)
|
|
.await;
|
|
}
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
if payload.enabled {
|
|
"Airplane mode enabled"
|
|
} else {
|
|
"Airplane mode disabled"
|
|
},
|
|
status,
|
|
)),
|
|
)
|
|
}
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<AirplaneModeResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<AirplaneModeResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/airplane-mode
|
|
pub async fn get_airplane_mode_handler(State(conn): State<Arc<Connection>>) -> impl IntoResponse {
|
|
match get_airplane_mode(&conn).await {
|
|
Ok(status) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", status)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<AirplaneModeResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
// ============ 短信功能 ============
|
|
|
|
use crate::db::{Database, EsimEuiccCacheEntry, EsimProfileCacheEntry};
|
|
|
|
fn schedule_sms_db_maintenance(app: &AppState, deleted: usize) {
|
|
if deleted < SMS_DB_MAINTENANCE_DELETE_THRESHOLD {
|
|
return;
|
|
}
|
|
|
|
if app.sms_db_maintenance_pending.swap(true, Ordering::SeqCst) {
|
|
info!(
|
|
deleted,
|
|
threshold = SMS_DB_MAINTENANCE_DELETE_THRESHOLD,
|
|
"SMS database maintenance already scheduled"
|
|
);
|
|
return;
|
|
}
|
|
|
|
let db = Arc::clone(&app.database);
|
|
let pending = Arc::clone(&app.sms_db_maintenance_pending);
|
|
tokio::spawn(async move {
|
|
info!(
|
|
deleted,
|
|
delay_secs = SMS_DB_MAINTENANCE_DELAY_SECS,
|
|
"SMS database maintenance scheduled"
|
|
);
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(
|
|
SMS_DB_MAINTENANCE_DELAY_SECS,
|
|
))
|
|
.await;
|
|
|
|
let result = tokio::task::spawn_blocking(move || db.vacuum()).await;
|
|
match result {
|
|
Ok(Ok(())) => info!("SMS database maintenance completed"),
|
|
Ok(Err(err)) => warn!(error = %err, "SMS database maintenance failed"),
|
|
Err(err) => warn!(error = %err, "SMS database maintenance task failed"),
|
|
}
|
|
pending.store(false, Ordering::SeqCst);
|
|
});
|
|
}
|
|
|
|
/// POST /api/sms/send
|
|
pub async fn send_sms_handler(
|
|
State(conn): State<Arc<Connection>>,
|
|
State(db): State<Arc<Database>>,
|
|
Json(payload): Json<SendSmsRequest>,
|
|
) -> impl IntoResponse {
|
|
match send_sms(&conn, &payload.phone_number, &payload.content).await {
|
|
Ok(path) => {
|
|
// 存入数据库
|
|
let _ = db.insert_sms(
|
|
"outgoing",
|
|
&payload.phone_number,
|
|
&payload.content,
|
|
"sent",
|
|
None,
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"SMS sent",
|
|
json!({ "path": path }),
|
|
)),
|
|
)
|
|
}
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<serde_json::Value>::error(format!(
|
|
"Failed to send SMS: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/sms/list
|
|
pub async fn get_sms_list_handler(
|
|
State(db): State<Arc<Database>>,
|
|
Query(params): Query<SmsListRequest>,
|
|
) -> (StatusCode, Json<ApiResponse<SmsListResponse>>) {
|
|
let limit = if params.limit > 0 { params.limit } else { 50 };
|
|
let offset = if params.offset >= 0 { params.offset } else { 0 };
|
|
let direction = params
|
|
.direction
|
|
.as_deref()
|
|
.filter(|value| matches!(*value, "incoming" | "outgoing"));
|
|
|
|
match db.get_sms_messages(limit, offset, direction) {
|
|
Ok(messages) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Success",
|
|
SmsListResponse { messages },
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<SmsListResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/sms/conversation
|
|
pub async fn get_sms_conversation_handler(
|
|
State(db): State<Arc<Database>>,
|
|
Query(params): Query<SmsConversationRequest>,
|
|
) -> (StatusCode, Json<ApiResponse<SmsListResponse>>) {
|
|
let limit = if params.limit > 0 { params.limit } else { 50 };
|
|
match db.get_sms_conversation(¶ms.phone_number, limit) {
|
|
Ok(messages) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Success",
|
|
SmsListResponse { messages },
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<SmsListResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/sms/stats
|
|
pub async fn get_sms_stats_handler(
|
|
State(db): State<Arc<Database>>,
|
|
) -> (StatusCode, Json<ApiResponse<SmsStatsResponse>>) {
|
|
match db.get_sms_stats() {
|
|
Ok(stats) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", stats)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<SmsStatsResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/sms/clear
|
|
pub async fn clear_sms_handler(
|
|
State(app): State<AppState>,
|
|
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
|
|
let deleted = app
|
|
.database
|
|
.get_sms_stats()
|
|
.map(|stats| stats.total.max(0) as usize)
|
|
.unwrap_or(SMS_DB_MAINTENANCE_DELETE_THRESHOLD);
|
|
|
|
match app.database.clear_all_sms() {
|
|
Ok(_) => {
|
|
schedule_sms_db_maintenance(&app, deleted);
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"All SMS cleared",
|
|
json!({ "deleted": deleted }),
|
|
)),
|
|
)
|
|
}
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::error(format!("Failed: {}", e))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// DELETE /api/sms/message/{id}
|
|
pub async fn delete_sms_message_handler(
|
|
State(db): State<Arc<Database>>,
|
|
Path(id): Path<i64>,
|
|
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
|
|
match db.delete_sms(id) {
|
|
Ok(deleted) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"SMS deleted",
|
|
json!({ "deleted": deleted }),
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::error(format!("Failed: {}", e))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// DELETE /api/sms/conversation/{phone_number}
|
|
pub async fn delete_sms_conversation_handler(
|
|
State(app): State<AppState>,
|
|
Path(phone_number): Path<String>,
|
|
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
|
|
match app.database.delete_sms_conversation(&phone_number) {
|
|
Ok(deleted) => {
|
|
schedule_sms_db_maintenance(&app, deleted);
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"SMS conversation deleted",
|
|
json!({ "deleted": deleted }),
|
|
)),
|
|
)
|
|
}
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::error(format!("Failed: {}", e))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/sms/batch-delete
|
|
pub async fn delete_sms_batch_handler(
|
|
State(app): State<AppState>,
|
|
Json(payload): Json<SmsBatchDeleteRequest>,
|
|
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
|
|
if payload.ids.is_empty() && payload.phone_numbers.is_empty() {
|
|
return (StatusCode::OK, Json(ApiResponse::error("No SMS selected")));
|
|
}
|
|
|
|
match app
|
|
.database
|
|
.delete_sms_batch(&payload.ids, &payload.phone_numbers)
|
|
{
|
|
Ok(deleted) => {
|
|
schedule_sms_db_maintenance(&app, deleted);
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"SMS batch deleted",
|
|
json!({ "deleted": deleted }),
|
|
)),
|
|
)
|
|
}
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::error(format!("Failed: {}", e))),
|
|
),
|
|
}
|
|
}
|
|
|
|
// ============ 系统信息 ============
|
|
|
|
/// 读取温度传感器数据
|
|
// ============ 电话功能 ============
|
|
|
|
async fn track_call_start(
|
|
app: &AppState,
|
|
path: &str,
|
|
direction: &str,
|
|
phone_number: &str,
|
|
answered: bool,
|
|
) {
|
|
if let Ok(id) = app.database.insert_call(direction, phone_number, answered) {
|
|
let mut active = app.active_calls.lock().await;
|
|
active.insert(
|
|
path.to_string(),
|
|
crate::state::ActiveCallRecord {
|
|
id,
|
|
answered_at: answered.then(std::time::Instant::now),
|
|
answered,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
async fn mark_tracked_call_answered(app: &AppState, path: &str) {
|
|
let mut active = app.active_calls.lock().await;
|
|
if let Some(record) = active.get_mut(path) {
|
|
record.answered = true;
|
|
if record.answered_at.is_none() {
|
|
record.answered_at = Some(std::time::Instant::now());
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn finish_tracked_call(app: &AppState, path: &str, answered_now: bool) {
|
|
let mut record = {
|
|
let mut active = app.active_calls.lock().await;
|
|
active.remove(path)
|
|
};
|
|
if let Some(ref mut record) = record {
|
|
if answered_now && record.answered_at.is_none() {
|
|
record.answered_at = Some(std::time::Instant::now());
|
|
}
|
|
let duration = record
|
|
.answered_at
|
|
.map(|at| at.elapsed().as_secs() as i64)
|
|
.unwrap_or(0);
|
|
let _ = app
|
|
.database
|
|
.update_call_end(record.id, duration, record.answered || answered_now);
|
|
}
|
|
}
|
|
|
|
pub async fn get_calls_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
match list_current_calls(&app.dbus_conn).await {
|
|
Ok(data) => {
|
|
for call in &data.calls {
|
|
if matches!(call.state.as_str(), "active" | "held") {
|
|
mark_tracked_call_answered(&app, &call.path).await;
|
|
}
|
|
}
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
)
|
|
}
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<CallListResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
pub async fn dial_call_handler(
|
|
State(app): State<AppState>,
|
|
Json(payload): Json<MakeCallRequest>,
|
|
) -> impl IntoResponse {
|
|
let phone_number = payload.phone_number.trim().to_string();
|
|
if phone_number.is_empty() {
|
|
return (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<serde_json::Value>::error(
|
|
"Phone number is required",
|
|
)),
|
|
);
|
|
}
|
|
match make_call(&app.dbus_conn, &phone_number).await {
|
|
Ok(path) => {
|
|
track_call_start(&app, &path, "outgoing", &phone_number, false).await;
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Call started",
|
|
json!({ "path": path }),
|
|
)),
|
|
)
|
|
}
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<serde_json::Value>::error(format!(
|
|
"Failed to dial: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
pub async fn hangup_call_handler(
|
|
State(app): State<AppState>,
|
|
Json(payload): Json<HangupCallRequest>,
|
|
) -> impl IntoResponse {
|
|
let before = get_call_by_path(&app.dbus_conn, &payload.path).await.ok();
|
|
match hangup_call(&app.dbus_conn, &payload.path).await {
|
|
Ok(()) => {
|
|
let answered = before
|
|
.as_ref()
|
|
.map(|call| call.state == "active" || call.state == "held")
|
|
.unwrap_or(false);
|
|
finish_tracked_call(&app, &payload.path, answered).await;
|
|
if let Some(call) = before {
|
|
if call.direction == "incoming"
|
|
&& matches!(call.state.as_str(), "incoming" | "waiting")
|
|
{
|
|
let _ = app
|
|
.database
|
|
.insert_call("missed", &call.phone_number, false);
|
|
}
|
|
}
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Call hung up", json!({}))),
|
|
)
|
|
}
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<serde_json::Value>::error(format!(
|
|
"Failed to hang up: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
pub async fn hangup_all_calls_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
let before = list_current_calls(&app.dbus_conn).await.ok();
|
|
match hangup_all_calls(&app.dbus_conn).await {
|
|
Ok(()) => {
|
|
if let Some(list) = before {
|
|
for call in list.calls {
|
|
let answered = call.state == "active" || call.state == "held";
|
|
finish_tracked_call(&app, &call.path, answered).await;
|
|
if call.direction == "incoming"
|
|
&& matches!(call.state.as_str(), "incoming" | "waiting")
|
|
{
|
|
let _ = app
|
|
.database
|
|
.insert_call("missed", &call.phone_number, false);
|
|
}
|
|
}
|
|
}
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"All calls hung up",
|
|
json!({}),
|
|
)),
|
|
)
|
|
}
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<serde_json::Value>::error(format!(
|
|
"Failed to hang up calls: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
pub async fn answer_call_handler(
|
|
State(app): State<AppState>,
|
|
Json(payload): Json<HangupCallRequest>,
|
|
) -> impl IntoResponse {
|
|
let before = get_call_by_path(&app.dbus_conn, &payload.path).await.ok();
|
|
match answer_call(&app.dbus_conn, &payload.path).await {
|
|
Ok(()) => {
|
|
if let Some(call) = before {
|
|
track_call_start(&app, &payload.path, "incoming", &call.phone_number, true).await;
|
|
mark_tracked_call_answered(&app, &payload.path).await;
|
|
}
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Call answered",
|
|
json!({}),
|
|
)),
|
|
)
|
|
}
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<serde_json::Value>::error(format!(
|
|
"Failed to answer call: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
pub async fn get_call_history_handler(
|
|
State(db): State<Arc<Database>>,
|
|
Query(params): Query<CallHistoryRequest>,
|
|
) -> (StatusCode, Json<ApiResponse<CallHistoryResponse>>) {
|
|
let limit = if params.limit > 0 { params.limit } else { 50 };
|
|
let offset = if params.offset >= 0 { params.offset } else { 0 };
|
|
match (db.get_call_history(limit, offset), db.get_call_stats()) {
|
|
(Ok(records), Ok(stats)) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Success",
|
|
CallHistoryResponse { records, stats },
|
|
)),
|
|
),
|
|
(Err(e), _) | (_, Err(e)) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<CallHistoryResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
pub async fn delete_call_history_handler(
|
|
State(db): State<Arc<Database>>,
|
|
Path(id): Path<i64>,
|
|
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
|
|
match db.delete_call(id) {
|
|
Ok(()) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Call record deleted",
|
|
json!({}),
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::error(format!("Failed: {}", e))),
|
|
),
|
|
}
|
|
}
|
|
|
|
pub async fn clear_call_history_handler(
|
|
State(db): State<Arc<Database>>,
|
|
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
|
|
match db.clear_all_calls() {
|
|
Ok(()) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Call history cleared",
|
|
json!({}),
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::error(format!("Failed: {}", e))),
|
|
),
|
|
}
|
|
}
|
|
|
|
pub async fn get_call_settings_handler(State(conn): State<Arc<Connection>>) -> impl IntoResponse {
|
|
match get_call_settings(&conn).await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<CallSettingsResponse>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
pub async fn set_call_settings_handler(
|
|
State(conn): State<Arc<Connection>>,
|
|
Json(payload): Json<SetCallSettingRequest>,
|
|
) -> impl IntoResponse {
|
|
if payload.property != "VoiceCallWaiting" {
|
|
return (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<serde_json::Value>::error(
|
|
"Only VoiceCallWaiting is supported by ModemManager",
|
|
)),
|
|
);
|
|
}
|
|
let enabled = matches!(payload.value.as_str(), "enabled" | "on" | "true" | "1");
|
|
match set_call_waiting(&conn, enabled).await {
|
|
Ok(()) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Call setting updated",
|
|
json!({}),
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<serde_json::Value>::error(format!(
|
|
"Failed to update call setting: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
pub async fn get_call_volume_handler() -> impl IntoResponse {
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<CallVolumeResponse>::error(
|
|
"Call volume control is not exposed by ModemManager on this backend",
|
|
)),
|
|
)
|
|
}
|
|
|
|
pub async fn set_call_volume_handler(
|
|
Json(payload): Json<SetCallVolumeRequest>,
|
|
) -> impl IntoResponse {
|
|
let _ = (
|
|
payload.speaker_volume,
|
|
payload.microphone_volume,
|
|
payload.muted,
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<CallVolumeResponse>::error(
|
|
"Call volume control is not exposed by ModemManager on this backend",
|
|
)),
|
|
)
|
|
}
|
|
|
|
pub async fn get_call_forwarding_handler() -> impl IntoResponse {
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<CallForwardingResponse>::error(
|
|
"Call forwarding is not exposed by ModemManager on this backend",
|
|
)),
|
|
)
|
|
}
|
|
|
|
pub async fn set_call_forwarding_handler(
|
|
Json(payload): Json<SetCallForwardingRequest>,
|
|
) -> impl IntoResponse {
|
|
let _ = (payload.forward_type, payload.number, payload.timeout);
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<CallForwardingResponse>::error(
|
|
"Call forwarding is not exposed by ModemManager on this backend",
|
|
)),
|
|
)
|
|
}
|
|
|
|
pub async fn get_ims_status_handler() -> impl IntoResponse {
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<ImsStatusResponse>::error(
|
|
"IMS status is not exposed by ModemManager on this backend",
|
|
)),
|
|
)
|
|
}
|
|
|
|
pub async fn get_voicemail_status_handler() -> impl IntoResponse {
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<VoicemailStatusResponse>::error(
|
|
"Voicemail status is not exposed by ModemManager on this backend",
|
|
)),
|
|
)
|
|
}
|
|
|
|
pub(crate) fn temperature_sensor_label(sensor_type: &str, zone: &str) -> String {
|
|
let source = if sensor_type.trim().is_empty() {
|
|
if zone.trim().is_empty() {
|
|
"unknown"
|
|
} else {
|
|
zone.trim()
|
|
}
|
|
} else {
|
|
sensor_type.trim()
|
|
};
|
|
let normalized = source.to_ascii_lowercase().replace('_', "-");
|
|
|
|
if ["modem", "baseband", "wwan", "qmi", "mhi"]
|
|
.iter()
|
|
.any(|pattern| normalized.contains(pattern))
|
|
{
|
|
return "基带".to_string();
|
|
}
|
|
if ["gpu", "adreno"]
|
|
.iter()
|
|
.any(|pattern| normalized.contains(pattern))
|
|
{
|
|
return "GPU".to_string();
|
|
}
|
|
if ["camera", "cam", "isp"]
|
|
.iter()
|
|
.any(|pattern| normalized.contains(pattern))
|
|
{
|
|
return "摄像头".to_string();
|
|
}
|
|
if ["wifi", "wlan"]
|
|
.iter()
|
|
.any(|pattern| normalized.contains(pattern))
|
|
{
|
|
return "Wi-Fi".to_string();
|
|
}
|
|
if ["battery", "batt"]
|
|
.iter()
|
|
.any(|pattern| normalized.contains(pattern))
|
|
{
|
|
return "电池".to_string();
|
|
}
|
|
if ["charger", "charge"]
|
|
.iter()
|
|
.any(|pattern| normalized.contains(pattern))
|
|
{
|
|
return "充电".to_string();
|
|
}
|
|
if ["pmic", "power"]
|
|
.iter()
|
|
.any(|pattern| normalized.contains(pattern))
|
|
{
|
|
return "电源管理".to_string();
|
|
}
|
|
if ["soc", "tsens"]
|
|
.iter()
|
|
.any(|pattern| normalized.contains(pattern))
|
|
{
|
|
return "SoC".to_string();
|
|
}
|
|
if ["skin", "shell", "case"]
|
|
.iter()
|
|
.any(|pattern| normalized.contains(pattern))
|
|
{
|
|
return "外壳".to_string();
|
|
}
|
|
if ["ambient", "board"]
|
|
.iter()
|
|
.any(|pattern| normalized.contains(pattern))
|
|
{
|
|
return "环境".to_string();
|
|
}
|
|
|
|
if let Some((first, second)) = extract_number_range_after(&normalized, "cpu") {
|
|
return second
|
|
.map(|second| format!("CPU {first}-{second}"))
|
|
.unwrap_or_else(|| format!("CPU {first}"));
|
|
}
|
|
if normalized.contains("cpu") {
|
|
return "CPU".to_string();
|
|
}
|
|
|
|
if let Some((first, second)) = extract_number_range_after(&normalized, "core") {
|
|
return second
|
|
.map(|second| format!("核心 {first}-{second}"))
|
|
.unwrap_or_else(|| format!("核心 {first}"));
|
|
}
|
|
if normalized.contains("core") {
|
|
return "核心".to_string();
|
|
}
|
|
|
|
let cleaned = source
|
|
.replace(|ch: char| matches!(ch, '-' | '_' | ' '), " ")
|
|
.split_whitespace()
|
|
.filter(|part| {
|
|
!matches!(
|
|
part.to_ascii_lowercase().as_str(),
|
|
"thermal" | "therm" | "temperature" | "temp" | "sensor" | "zone"
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
|
|
if cleaned.is_empty() {
|
|
source.to_string()
|
|
} else {
|
|
cleaned
|
|
}
|
|
}
|
|
|
|
fn extract_number_range_after(value: &str, prefix: &str) -> Option<(String, Option<String>)> {
|
|
let start = value.find(prefix)? + prefix.len();
|
|
let chars = value[start..].char_indices();
|
|
let mut first_start = None;
|
|
for (index, ch) in chars {
|
|
if ch.is_ascii_digit() {
|
|
first_start = Some(start + index);
|
|
break;
|
|
}
|
|
}
|
|
let first_start = first_start?;
|
|
let first_end = value[first_start..]
|
|
.char_indices()
|
|
.find_map(|(index, ch)| (!ch.is_ascii_digit()).then_some(first_start + index))
|
|
.unwrap_or(value.len());
|
|
let first = value[first_start..first_end].to_string();
|
|
|
|
let after_first = &value[first_end..];
|
|
let mut second_start = None;
|
|
for (index, ch) in after_first.char_indices() {
|
|
if ch.is_ascii_digit() {
|
|
second_start = Some(first_end + index);
|
|
break;
|
|
}
|
|
if ch.is_ascii_alphabetic() {
|
|
break;
|
|
}
|
|
}
|
|
let Some(second_start) = second_start else {
|
|
return Some((first, None));
|
|
};
|
|
let second_end = value[second_start..]
|
|
.char_indices()
|
|
.find_map(|(index, ch)| (!ch.is_ascii_digit()).then_some(second_start + index))
|
|
.unwrap_or(value.len());
|
|
Some((first, Some(value[second_start..second_end].to_string())))
|
|
}
|
|
|
|
pub(crate) fn read_temperature_sensors() -> Vec<ThermalZone> {
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
let thermal_path = Path::new("/sys/class/thermal");
|
|
let mut sensors = Vec::new();
|
|
|
|
if let Ok(entries) = fs::read_dir(thermal_path) {
|
|
for entry in entries.flatten() {
|
|
let file_name = entry.file_name();
|
|
let name = file_name.to_string_lossy();
|
|
|
|
if name.starts_with("thermal_zone") {
|
|
let zone_path = entry.path();
|
|
let sensor_type = fs::read_to_string(zone_path.join("type"))
|
|
.map(|s| s.trim().to_string())
|
|
.unwrap_or_default();
|
|
let temperature = fs::read_to_string(zone_path.join("temp"))
|
|
.ok()
|
|
.and_then(|s| s.trim().parse::<i32>().ok())
|
|
.map(|t| t as f64 / 1000.0)
|
|
.unwrap_or(0.0);
|
|
|
|
let label = temperature_sensor_label(&sensor_type, &name);
|
|
sensors.push(ThermalZone {
|
|
zone: name.to_string(),
|
|
sensor_type,
|
|
label,
|
|
temperature,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
sensors.sort_by(|a, b| a.zone.cmp(&b.zone));
|
|
sensors
|
|
}
|
|
|
|
static SYSTEM_STATS_SNAPSHOT: OnceLock<Arc<RwLock<Option<SystemStatsResponse>>>> = OnceLock::new();
|
|
const SYSTEM_STATS_LOW_FREQUENCY_REFRESH_SECS: u64 = 10;
|
|
|
|
#[derive(Default)]
|
|
struct SystemStatsSamplerState {
|
|
previous_network: HashMap<String, (u64, u64)>,
|
|
last_low_frequency_refresh: Option<Instant>,
|
|
memory: MemoryInfo,
|
|
disk: Vec<DiskInfo>,
|
|
uptime: UptimeInfo,
|
|
system_info: SystemInfo,
|
|
temperature: Vec<ThermalZone>,
|
|
}
|
|
|
|
fn system_stats_snapshot() -> Arc<RwLock<Option<SystemStatsResponse>>> {
|
|
Arc::clone(SYSTEM_STATS_SNAPSHOT.get_or_init(|| Arc::new(RwLock::new(None))))
|
|
}
|
|
|
|
async fn collect_system_stats_snapshot(
|
|
dbus_conn: &Connection,
|
|
state: &mut SystemStatsSamplerState,
|
|
interval_seconds: f64,
|
|
) -> Result<SystemStatsResponse, String> {
|
|
let interfaces =
|
|
get_active_interfaces().map_err(|e| format!("Failed to get interfaces: {}", e))?;
|
|
|
|
let mut speed_data = Vec::new();
|
|
let elapsed = interval_seconds.max(0.001);
|
|
for iface in &interfaces {
|
|
if let Ok((rx, tx)) = read_interface_stats(iface, Some(dbus_conn)).await {
|
|
let (rx_speed, tx_speed) = state
|
|
.previous_network
|
|
.get(iface)
|
|
.map(|(prev_rx, prev_tx)| {
|
|
(
|
|
(rx.saturating_sub(*prev_rx) as f64 / elapsed) as u64,
|
|
(tx.saturating_sub(*prev_tx) as f64 / elapsed) as u64,
|
|
)
|
|
})
|
|
.unwrap_or((0, 0));
|
|
speed_data.push(NetworkSpeed {
|
|
interface: iface.clone(),
|
|
rx_bytes_per_sec: rx_speed,
|
|
tx_bytes_per_sec: tx_speed,
|
|
total_rx_bytes: rx,
|
|
total_tx_bytes: tx,
|
|
});
|
|
state.previous_network.insert(iface.clone(), (rx, tx));
|
|
}
|
|
}
|
|
state
|
|
.previous_network
|
|
.retain(|iface, _| interfaces.iter().any(|current| current == iface));
|
|
|
|
let cpu_usage = sample_cpu_usage().await.unwrap_or(0.0);
|
|
let mut cpu_load = read_cpu_load_sync().unwrap_or_default();
|
|
cpu_load.load_percent = cpu_usage;
|
|
|
|
let should_refresh_low_frequency = state
|
|
.last_low_frequency_refresh
|
|
.map(|last| last.elapsed() >= Duration::from_secs(SYSTEM_STATS_LOW_FREQUENCY_REFRESH_SECS))
|
|
.unwrap_or(true);
|
|
if should_refresh_low_frequency {
|
|
let (total, available, cached, buffers) = read_memory_info()?;
|
|
let used = total.saturating_sub(available);
|
|
let used_percent = if total > 0 {
|
|
(used as f64 / total as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
let (uptime, idle) = read_uptime()?;
|
|
state.memory = MemoryInfo {
|
|
total_bytes: total,
|
|
available_bytes: available,
|
|
used_bytes: used,
|
|
used_percent,
|
|
cached_bytes: cached,
|
|
buffers_bytes: buffers,
|
|
};
|
|
state.disk = read_disk_info();
|
|
state.uptime = UptimeInfo {
|
|
uptime_seconds: uptime,
|
|
idle_seconds: idle,
|
|
uptime_formatted: format_uptime(uptime),
|
|
};
|
|
state.system_info = read_system_info()?;
|
|
state.temperature = read_temperature_sensors();
|
|
state.last_low_frequency_refresh = Some(Instant::now());
|
|
}
|
|
|
|
Ok(SystemStatsResponse {
|
|
network_speed: NetworkSpeedResponse {
|
|
interfaces: speed_data,
|
|
interval_seconds: elapsed,
|
|
},
|
|
memory: state.memory.clone(),
|
|
disk: state.disk.clone(),
|
|
cpu_load,
|
|
uptime: state.uptime.clone(),
|
|
system_info: state.system_info.clone(),
|
|
temperature: state.temperature.clone(),
|
|
})
|
|
}
|
|
|
|
pub fn spawn_system_stats_sampler(dbus_conn: Arc<Connection>) {
|
|
let snapshot = system_stats_snapshot();
|
|
tokio::spawn(async move {
|
|
let mut sampler_state = SystemStatsSamplerState::default();
|
|
let mut last_sample = Instant::now();
|
|
loop {
|
|
let elapsed = last_sample.elapsed().as_secs_f64().max(1.0);
|
|
last_sample = Instant::now();
|
|
match collect_system_stats_snapshot(&dbus_conn, &mut sampler_state, elapsed).await {
|
|
Ok(stats) => {
|
|
*snapshot.write().await = Some(stats);
|
|
}
|
|
Err(err) => warn!(error = %err, "Failed to sample system stats"),
|
|
}
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
}
|
|
});
|
|
}
|
|
|
|
/// GET /api/stats
|
|
pub async fn get_system_stats(State(_dbus_conn): State<Arc<Connection>>) -> impl IntoResponse {
|
|
let snapshot = system_stats_snapshot();
|
|
if let Some(data) = snapshot.read().await.clone() {
|
|
return (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", data)),
|
|
);
|
|
}
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"No system stats sample yet",
|
|
SystemStatsResponse::default(),
|
|
)),
|
|
)
|
|
}
|
|
|
|
/// GET /api/stats/cpu
|
|
pub async fn get_cpu_info() -> impl IntoResponse {
|
|
match read_cpu_info() {
|
|
Ok(info) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", info)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<CpuInfo>::error(format!("Failed: {}", e))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/connectivity
|
|
pub async fn get_connectivity_check() -> (StatusCode, Json<ApiResponse<ConnectivityCheckResponse>>)
|
|
{
|
|
// 两个 ping 并行执行,超时从 2s 缩短到 1s
|
|
let (ipv4_result, ipv6_result) = tokio::join!(
|
|
async_ping_host("223.5.5.5", false),
|
|
async_ping_host("2400:3200::1", true),
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Connectivity check completed",
|
|
ConnectivityCheckResponse {
|
|
ipv4: ipv4_result,
|
|
ipv6: ipv6_result,
|
|
},
|
|
)),
|
|
)
|
|
}
|
|
|
|
pub(crate) async fn async_ping_host(target: &str, is_ipv6: bool) -> PingResult {
|
|
let cmd = if is_ipv6 { "ping6" } else { "ping" };
|
|
let output = tokio::process::Command::new(cmd)
|
|
.args(["-c", "1", "-W", "1", target])
|
|
.output()
|
|
.await;
|
|
match output {
|
|
Ok(result) => {
|
|
if result.status.success() {
|
|
let stdout = String::from_utf8_lossy(&result.stdout);
|
|
let latency = parse_ping_latency(&stdout);
|
|
PingResult {
|
|
success: true,
|
|
latency_ms: latency,
|
|
target: target.to_string(),
|
|
error: None,
|
|
}
|
|
} else {
|
|
let stderr = String::from_utf8_lossy(&result.stderr);
|
|
PingResult {
|
|
success: false,
|
|
latency_ms: None,
|
|
target: target.to_string(),
|
|
error: Some(if stderr.is_empty() {
|
|
"Unreachable".to_string()
|
|
} else {
|
|
stderr.trim().to_string()
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
Err(e) => PingResult {
|
|
success: false,
|
|
latency_ms: None,
|
|
target: target.to_string(),
|
|
error: Some(format!("Failed: {}", e)),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn parse_ping_latency(output: &str) -> Option<f64> {
|
|
for line in output.lines() {
|
|
if let Some(time_pos) = line.find("time=") {
|
|
let after_time = &line[time_pos + 5..];
|
|
let num_str: String = after_time
|
|
.chars()
|
|
.take_while(|c| c.is_ascii_digit() || *c == '.')
|
|
.collect();
|
|
if let Ok(latency) = num_str.parse::<f64>() {
|
|
return Some(latency);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// POST /api/system/reboot
|
|
pub async fn system_reboot(
|
|
State(app): State<AppState>,
|
|
Json(payload): Json<SystemRebootRequest>,
|
|
) -> impl IntoResponse {
|
|
let delay = payload.delay_seconds;
|
|
app.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::SYSTEM_SERVICE_REBOOT_REQUESTED,
|
|
system_event_severity::WARNING,
|
|
system_event_status::TRIGGERED,
|
|
"system",
|
|
format!("用户触发系统重启,延迟 {} 秒执行", delay),
|
|
)
|
|
.await;
|
|
let system_events = Arc::clone(&app.system_event_emitter);
|
|
tokio::spawn(async move {
|
|
run_safe_os_reboot_sequence(delay, system_events).await;
|
|
});
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
format!("System will perform safe OS reboot in {} seconds", delay),
|
|
json!({ "delay_seconds": delay }),
|
|
)),
|
|
)
|
|
}
|
|
|
|
pub async fn run_safe_os_reboot_sequence(
|
|
delay_seconds: u32,
|
|
system_events: Arc<crate::system_event::SystemEventEmitter>,
|
|
) {
|
|
if delay_seconds > 0 {
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(delay_seconds as u64)).await;
|
|
}
|
|
|
|
info!("Starting safe OS reboot sequence");
|
|
|
|
if let Some(message) =
|
|
run_reboot_prep_command("disable modem radio", "mmcli", &["-m", "0", "-d"], false)
|
|
{
|
|
system_events
|
|
.emit_code(
|
|
system_event_codes::SYSTEM_SERVICE_REBOOT_PREP_FAILED,
|
|
system_event_severity::WARNING,
|
|
system_event_status::FAILED,
|
|
"disable modem radio",
|
|
message,
|
|
)
|
|
.await;
|
|
}
|
|
if let Some(message) = run_reboot_prep_command(
|
|
"stop ModemManager IPC service",
|
|
"systemctl",
|
|
&["stop", "ModemManager"],
|
|
false,
|
|
) {
|
|
system_events
|
|
.emit_code(
|
|
system_event_codes::SYSTEM_SERVICE_REBOOT_PREP_FAILED,
|
|
system_event_severity::WARNING,
|
|
system_event_status::FAILED,
|
|
"stop ModemManager IPC service",
|
|
message,
|
|
)
|
|
.await;
|
|
}
|
|
let _ = run_reboot_prep_command("stop qmi-proxy", "killall", &["qmi-proxy"], true);
|
|
cleanup_modemmanager_runtime_cache();
|
|
if let Some(message) = run_reboot_prep_command("flush filesystem cache", "sync", &[], false) {
|
|
system_events
|
|
.emit_code(
|
|
system_event_codes::SYSTEM_SERVICE_REBOOT_PREP_FAILED,
|
|
system_event_severity::WARNING,
|
|
system_event_status::FAILED,
|
|
"flush filesystem cache",
|
|
message,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
|
|
|
info!("Safe OS reboot preparation complete, executing reboot");
|
|
if let Err(err) = Command::new("reboot").output() {
|
|
error!(error = %err, "Failed to execute reboot command");
|
|
}
|
|
}
|
|
|
|
fn run_reboot_prep_command(
|
|
label: &str,
|
|
program: &str,
|
|
args: &[&str],
|
|
allow_failure: bool,
|
|
) -> Option<String> {
|
|
match Command::new(program).args(args).output() {
|
|
Ok(output) if output.status.success() => {
|
|
info!(step = label, "Safe OS reboot step completed");
|
|
None
|
|
}
|
|
Ok(output) => {
|
|
let severity = if allow_failure {
|
|
"optional"
|
|
} else {
|
|
"required"
|
|
};
|
|
warn_reboot_prep_failure(label, program, severity, &output);
|
|
if allow_failure {
|
|
None
|
|
} else {
|
|
Some(format!(
|
|
"重启预处理步骤失败: {label}; command={program}; status={}",
|
|
output.status
|
|
))
|
|
}
|
|
}
|
|
Err(err) if allow_failure => {
|
|
warn!(step = label, command = program, error = %err, "Optional safe OS reboot step failed");
|
|
None
|
|
}
|
|
Err(err) => {
|
|
warn!(step = label, command = program, error = %err, "Safe OS reboot step failed");
|
|
Some(format!(
|
|
"重启预处理步骤失败: {label}; command={program}; error={err}"
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cleanup_modemmanager_runtime_cache() {
|
|
const CACHE_DIR: &str = "/var/lib/ModemManager";
|
|
|
|
match fs::read_dir(CACHE_DIR) {
|
|
Ok(entries) => {
|
|
let mut removed = 0usize;
|
|
for entry in entries {
|
|
match entry {
|
|
Ok(entry) => {
|
|
let path = entry.path();
|
|
let result = if path.is_dir() {
|
|
fs::remove_dir_all(&path)
|
|
} else {
|
|
fs::remove_file(&path)
|
|
};
|
|
|
|
match result {
|
|
Ok(()) => removed += 1,
|
|
Err(err) => warn!(
|
|
path = %path.display(),
|
|
error = %err,
|
|
"Failed to remove ModemManager runtime cache entry"
|
|
),
|
|
}
|
|
}
|
|
Err(err) => warn!(
|
|
directory = CACHE_DIR,
|
|
error = %err,
|
|
"Failed to read ModemManager runtime cache entry"
|
|
),
|
|
}
|
|
}
|
|
info!(
|
|
directory = CACHE_DIR,
|
|
removed, "ModemManager runtime cache cleanup completed"
|
|
);
|
|
}
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
|
info!(
|
|
directory = CACHE_DIR,
|
|
"ModemManager runtime cache directory does not exist"
|
|
);
|
|
}
|
|
Err(err) => {
|
|
warn!(
|
|
directory = CACHE_DIR,
|
|
error = %err,
|
|
"Failed to open ModemManager runtime cache directory"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn warn_reboot_prep_failure(label: &str, program: &str, severity: &str, output: &Output) {
|
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
warn!(
|
|
step = label,
|
|
command = program,
|
|
severity = severity,
|
|
status = %output.status,
|
|
stderr = %stderr,
|
|
stdout = %stdout,
|
|
"Safe OS reboot step returned non-zero status"
|
|
);
|
|
}
|
|
|
|
// ============ 通知配置 ============
|
|
|
|
pub async fn restart_service_handler(State(app): State<AppState>) -> impl IntoResponse {
|
|
app.system_event_emitter
|
|
.emit_code(
|
|
system_event_codes::SYSTEM_SERVICE_SIMADMIN_RESTART_REQUESTED,
|
|
system_event_severity::WARNING,
|
|
system_event_status::TRIGGERED,
|
|
"simadmin",
|
|
"用户触发 SimAdmin 服务重启",
|
|
)
|
|
.await;
|
|
tokio::spawn(async move {
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
|
let _ = Command::new("systemctl")
|
|
.args(["restart", "simadmin"])
|
|
.output();
|
|
});
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"SimAdmin service will restart",
|
|
json!({}),
|
|
)),
|
|
)
|
|
}
|
|
|
|
use crate::config::ConfigManager;
|
|
use crate::notification::NotificationSender;
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
pub struct NotificationLogQuery {
|
|
#[serde(default, rename = "type")]
|
|
pub event_type: String,
|
|
#[serde(default)]
|
|
pub status: String,
|
|
#[serde(default)]
|
|
pub q: String,
|
|
#[serde(default)]
|
|
pub start_date: String,
|
|
#[serde(default)]
|
|
pub end_date: String,
|
|
#[serde(default = "default_notification_log_limit")]
|
|
pub limit: i64,
|
|
#[serde(default)]
|
|
pub offset: i64,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
pub struct NotificationLogClearRequest {
|
|
#[serde(default, rename = "type")]
|
|
pub event_type: String,
|
|
#[serde(default)]
|
|
pub status: String,
|
|
#[serde(default)]
|
|
pub start_date: String,
|
|
#[serde(default)]
|
|
pub end_date: String,
|
|
}
|
|
|
|
fn default_notification_log_limit() -> i64 {
|
|
50
|
|
}
|
|
|
|
/// GET /api/notifications/config
|
|
pub async fn get_notification_config_handler(
|
|
State(config_manager): State<Arc<ConfigManager>>,
|
|
) -> (
|
|
StatusCode,
|
|
Json<ApiResponse<crate::config::NotificationConfig>>,
|
|
) {
|
|
let config = config_manager.get_notifications();
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", config)),
|
|
)
|
|
}
|
|
|
|
/// POST /api/notifications/config
|
|
pub async fn set_notification_config_handler(
|
|
State(config_manager): State<Arc<ConfigManager>>,
|
|
Json(notification_config): Json<crate::config::NotificationConfig>,
|
|
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
|
|
match config_manager.set_notifications(notification_config) {
|
|
Ok(_) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Notification config updated",
|
|
json!({}),
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::error(format!("Failed: {}", e))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/notifications/test/{channel}
|
|
pub async fn test_notification_channel_handler(
|
|
Path(channel): Path<String>,
|
|
State(notification_sender): State<Arc<NotificationSender>>,
|
|
) -> (
|
|
StatusCode,
|
|
Json<ApiResponse<crate::models::WebhookTestResponse>>,
|
|
) {
|
|
match notification_sender.test_channel(&channel).await {
|
|
Ok(message) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Notification test successful",
|
|
WebhookTestResponse {
|
|
success: true,
|
|
message,
|
|
},
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Notification test failed",
|
|
WebhookTestResponse {
|
|
success: false,
|
|
message: e,
|
|
},
|
|
)),
|
|
),
|
|
}
|
|
}
|
|
|
|
// ============ OTA 更新 ============
|
|
|
|
/// GET /api/notifications/logs
|
|
pub async fn get_notification_logs_handler(
|
|
Query(query): Query<NotificationLogQuery>,
|
|
State(database): State<Arc<Database>>,
|
|
) -> (
|
|
StatusCode,
|
|
Json<ApiResponse<crate::db::NotificationLogsResponse>>,
|
|
) {
|
|
match database.get_notification_logs(
|
|
&query.event_type,
|
|
&query.status,
|
|
&query.q,
|
|
&query.start_date,
|
|
&query.end_date,
|
|
query.limit,
|
|
query.offset,
|
|
) {
|
|
Ok(logs) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", logs)),
|
|
),
|
|
Err(err) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::error(format!("Failed: {}", err))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/notifications/logs/clear
|
|
pub async fn clear_notification_logs_handler(
|
|
State(database): State<Arc<Database>>,
|
|
payload: Option<Json<NotificationLogClearRequest>>,
|
|
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
|
|
let filters = payload.map(|Json(value)| value).unwrap_or_default();
|
|
match database.clear_notification_logs(
|
|
&filters.event_type,
|
|
&filters.status,
|
|
&filters.start_date,
|
|
&filters.end_date,
|
|
) {
|
|
Ok(deleted) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Notification logs cleared",
|
|
json!({ "deleted": deleted }),
|
|
)),
|
|
),
|
|
Err(err) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::error(format!("Failed: {}", err))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/ota/status
|
|
pub async fn get_ota_status_handler() -> impl IntoResponse {
|
|
let status = crate::ota::get_ota_status();
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", status)),
|
|
)
|
|
}
|
|
|
|
/// POST /api/ota/upload
|
|
pub async fn upload_ota_handler(body: axum::body::Bytes) -> impl IntoResponse {
|
|
match crate::ota::handle_ota_upload(&body) {
|
|
Ok(response) => {
|
|
let message = if response.validation.valid {
|
|
"OTA uploaded and validated"
|
|
} else {
|
|
"OTA uploaded but validation failed"
|
|
};
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(message, response)),
|
|
)
|
|
}
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<crate::models::OtaUploadResponse>::error(
|
|
format!("Failed: {}", e),
|
|
)),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/ota/latest-release
|
|
pub async fn get_latest_ota_release_handler(
|
|
Json(req): Json<crate::models::OtaOnlinePrepareRequest>,
|
|
) -> impl IntoResponse {
|
|
let result: Result<crate::models::OtaLatestReleaseResponse, String> = async {
|
|
let include_builtin_proxies = req
|
|
.proxy_prefix
|
|
.as_ref()
|
|
.map(|prefix| !prefix.trim().is_empty())
|
|
.unwrap_or(false);
|
|
let proxy_prefix = crate::ota::normalize_proxy_prefix(req.proxy_prefix);
|
|
let client = crate::ota::build_ota_http_client()?;
|
|
|
|
crate::ota::fetch_latest_github_release(&client, &proxy_prefix, include_builtin_proxies)
|
|
.await
|
|
}
|
|
.await;
|
|
|
|
match result {
|
|
Ok(release) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", release)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<crate::models::OtaLatestReleaseResponse>::error(format!(
|
|
"Failed: {}. GitHub may have rate-limited this request; try again later or enable a proxy.",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/ota/online-prepare
|
|
pub async fn prepare_online_ota_handler(
|
|
Json(req): Json<crate::models::OtaOnlinePrepareRequest>,
|
|
) -> impl IntoResponse {
|
|
let result: Result<crate::models::OtaUploadResponse, String> = async {
|
|
let include_builtin_proxies = req
|
|
.proxy_prefix
|
|
.as_ref()
|
|
.map(|prefix| !prefix.trim().is_empty())
|
|
.unwrap_or(false);
|
|
let proxy_prefix = crate::ota::normalize_proxy_prefix(req.proxy_prefix);
|
|
let client = crate::ota::build_ota_http_client()?;
|
|
|
|
let release = crate::ota::fetch_latest_github_release(
|
|
&client,
|
|
&proxy_prefix,
|
|
include_builtin_proxies,
|
|
)
|
|
.await?;
|
|
|
|
let asset = crate::ota::supported_release_asset(&release)
|
|
.ok_or_else(|| "No supported OTA asset found in latest release".to_string())?;
|
|
|
|
if asset.size > crate::ota::MAX_OTA_BYTES {
|
|
return Err(format!(
|
|
"OTA asset is too large: {} bytes exceeds {} bytes",
|
|
asset.size,
|
|
crate::ota::MAX_OTA_BYTES
|
|
));
|
|
}
|
|
|
|
let bytes = crate::ota::download_ota_asset_bytes(
|
|
&client,
|
|
&proxy_prefix,
|
|
include_builtin_proxies,
|
|
asset,
|
|
)
|
|
.await?;
|
|
|
|
crate::ota::handle_ota_upload(&bytes)
|
|
}
|
|
.await;
|
|
|
|
match result {
|
|
Ok(response) => {
|
|
let message = if response.validation.valid {
|
|
"Online OTA downloaded and validated"
|
|
} else {
|
|
"Online OTA downloaded but validation failed"
|
|
};
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(message, response)),
|
|
)
|
|
}
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<crate::models::OtaUploadResponse>::error(
|
|
format!("Failed: {}", e),
|
|
)),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/ota/apply
|
|
pub async fn apply_ota_handler(
|
|
Json(req): Json<crate::models::OtaApplyRequest>,
|
|
) -> impl IntoResponse {
|
|
match crate::ota::apply_ota_update(req.restart_now) {
|
|
Ok(message) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
&message,
|
|
json!({ "applied": true }),
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<serde_json::Value>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/ota/cancel
|
|
pub async fn cancel_ota_handler() -> impl IntoResponse {
|
|
match crate::ota::cancel_pending_update() {
|
|
Ok(()) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Update cancelled",
|
|
json!({}),
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::<serde_json::Value>::error(format!(
|
|
"Failed: {}",
|
|
e
|
|
))),
|
|
),
|
|
}
|
|
}
|
|
|
|
fn default_log_limit() -> i64 {
|
|
100
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct AutomationLogQuery {
|
|
#[serde(default, rename = "type")]
|
|
pub task_type: String,
|
|
#[serde(default)]
|
|
pub status: String,
|
|
#[serde(default)]
|
|
pub q: String,
|
|
#[serde(default)]
|
|
pub start_date: String,
|
|
#[serde(default)]
|
|
pub end_date: String,
|
|
#[serde(default = "default_log_limit")]
|
|
pub limit: i64,
|
|
#[serde(default)]
|
|
pub offset: i64,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Default)]
|
|
pub struct AutomationLogClearRequest {
|
|
#[serde(default, rename = "type")]
|
|
pub task_type: String,
|
|
#[serde(default)]
|
|
pub status: String,
|
|
#[serde(default)]
|
|
pub start_date: String,
|
|
#[serde(default)]
|
|
pub end_date: String,
|
|
}
|
|
|
|
/// GET /api/automation/config
|
|
pub async fn get_automation_config_handler(
|
|
State(config_manager): State<Arc<ConfigManager>>,
|
|
) -> (
|
|
StatusCode,
|
|
Json<ApiResponse<crate::config::AutomationConfig>>,
|
|
) {
|
|
let config = config_manager.get_automation_config();
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", config)),
|
|
)
|
|
}
|
|
|
|
/// POST /api/automation/config
|
|
pub async fn set_automation_config_handler(
|
|
State(config_manager): State<Arc<ConfigManager>>,
|
|
Json(config): Json<crate::config::AutomationConfig>,
|
|
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
|
|
match config_manager.set_automation_config(config) {
|
|
Ok(_) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Automation config updated",
|
|
json!({}),
|
|
)),
|
|
),
|
|
Err(e) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::error(format!("Failed: {}", e))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// GET /api/automation/logs
|
|
pub async fn get_automation_logs_handler(
|
|
Query(query): Query<AutomationLogQuery>,
|
|
State(database): State<Arc<Database>>,
|
|
) -> (
|
|
StatusCode,
|
|
Json<ApiResponse<crate::db::AutomationLogsResponse>>,
|
|
) {
|
|
match database.get_automation_logs(
|
|
&query.task_type,
|
|
&query.status,
|
|
&query.q,
|
|
&query.start_date,
|
|
&query.end_date,
|
|
query.limit,
|
|
query.offset,
|
|
) {
|
|
Ok(logs) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message("Success", logs)),
|
|
),
|
|
Err(err) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::error(format!("Failed: {}", err))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/automation/logs/clear
|
|
pub async fn clear_automation_logs_handler(
|
|
State(database): State<Arc<Database>>,
|
|
payload: Option<Json<AutomationLogClearRequest>>,
|
|
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
|
|
let filters = payload.map(|Json(value)| value).unwrap_or_default();
|
|
match database.clear_automation_logs(
|
|
&filters.task_type,
|
|
&filters.status,
|
|
&filters.start_date,
|
|
&filters.end_date,
|
|
) {
|
|
Ok(deleted) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"Automation logs cleared",
|
|
json!({ "deleted": deleted }),
|
|
)),
|
|
),
|
|
Err(err) => (
|
|
StatusCode::OK,
|
|
Json(ApiResponse::error(format!("Failed: {}", err))),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// POST /api/automation/test/{task_id}
|
|
pub async fn test_automation_task_handler(
|
|
Path(task_id): Path<String>,
|
|
State(app_state): State<AppState>,
|
|
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
|
|
let config = app_state.config_manager.get_automation_config();
|
|
let task = config.tasks.iter().find(|t| t.id == task_id).cloned();
|
|
|
|
let Some(task) = task else {
|
|
return (StatusCode::OK, Json(ApiResponse::error("自动化任务不存在")));
|
|
};
|
|
|
|
tokio::spawn(async move {
|
|
let registry = crate::automation::tasks::TaskRegistry::new();
|
|
let task_type = match &task.action {
|
|
crate::config::AutomationAction::RestartBaseband => "restart_baseband",
|
|
crate::config::AutomationAction::RebootDevice { .. } => "reboot_device",
|
|
crate::config::AutomationAction::SendSms { .. } => "send_sms",
|
|
};
|
|
|
|
let handler = match registry.get(task_type) {
|
|
Some(h) => h,
|
|
None => {
|
|
let err_msg = format!("未找到该任务类型的处理器: {}", task_type);
|
|
let _ = app_state
|
|
.database
|
|
.insert_automation_log(&task.id, &task.name, task_type, "failed", &err_msg);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let mut delay_secs = 0u64;
|
|
let params = match &task.action {
|
|
crate::config::AutomationAction::RestartBaseband => serde_json::Value::Null,
|
|
crate::config::AutomationAction::RebootDevice { delay_seconds } => {
|
|
serde_json::json!({ "delay_seconds": delay_seconds })
|
|
}
|
|
crate::config::AutomationAction::SendSms {
|
|
phone_number,
|
|
content,
|
|
random_delay_seconds,
|
|
retry_limit,
|
|
} => {
|
|
delay_secs = u64::from(random_delay_seconds.unwrap_or(0));
|
|
serde_json::json!({
|
|
"phone_number": phone_number,
|
|
"content": content,
|
|
"random_delay_seconds": random_delay_seconds,
|
|
"retry_limit": retry_limit
|
|
})
|
|
}
|
|
};
|
|
|
|
let result = tokio::time::timeout(
|
|
tokio::time::Duration::from_secs(60 + delay_secs),
|
|
handler.execute(&app_state, ¶ms),
|
|
)
|
|
.await;
|
|
|
|
let (status, detail) = match result {
|
|
Ok(Ok(_)) => ("success", "执行成功".to_string()),
|
|
Ok(Err(e)) => ("failed", format!("执行失败: {}", e)),
|
|
Err(_) => ("failed", "执行超时 (超过60秒限制)".to_string()),
|
|
};
|
|
|
|
let _ = app_state
|
|
.database
|
|
.insert_automation_log(&task.id, &task.name, task_type, status, &detail);
|
|
|
|
let event = crate::notification::AutomationEvent {
|
|
task_id: task.id.clone(),
|
|
task_name: task.name.clone(),
|
|
task_type: task_type.to_string(),
|
|
status: status.to_string(),
|
|
message: detail.clone(),
|
|
timestamp: crate::db::beijing_sms_now_string(),
|
|
};
|
|
|
|
let _ = app_state
|
|
.notification_sender
|
|
.forward_automation_event(&event)
|
|
.await;
|
|
});
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(ApiResponse::success_with_message(
|
|
"任务已在后台下发立即执行",
|
|
json!({}),
|
|
)),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::modem_manager::SimIdentity;
|
|
|
|
#[test]
|
|
fn enriches_enabled_esim_profile_from_current_sim_identity() {
|
|
let mut profiles = vec![
|
|
EsimProfile {
|
|
iccid: "profile-a".to_string(),
|
|
state: "disabled".to_string(),
|
|
..Default::default()
|
|
},
|
|
EsimProfile {
|
|
iccid: "profile-b".to_string(),
|
|
state: "disabled".to_string(),
|
|
..Default::default()
|
|
},
|
|
];
|
|
let identity = SimIdentity {
|
|
iccid: "profile-b".to_string(),
|
|
imsi: "234336".to_string(),
|
|
operator_id: "234336".to_string(),
|
|
};
|
|
|
|
enrich_profiles_with_current_identity(&mut profiles, &identity);
|
|
|
|
assert_eq!(profiles[1].state, "enabled");
|
|
assert_eq!(profiles[1].imsi.as_deref(), Some("234336"));
|
|
assert_eq!(profiles[1].mcc.as_deref(), Some("234"));
|
|
assert_eq!(profiles[1].mnc.as_deref(), Some("336"));
|
|
assert!(profiles[0].mcc.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn enriches_unknown_cached_profile_states_from_current_sim_identity() {
|
|
let mut profiles = vec![
|
|
EsimProfile {
|
|
iccid: "profile-a".to_string(),
|
|
state: "unknown".to_string(),
|
|
..Default::default()
|
|
},
|
|
EsimProfile {
|
|
iccid: "profile-b".to_string(),
|
|
state: "unknown".to_string(),
|
|
..Default::default()
|
|
},
|
|
];
|
|
let identity = SimIdentity {
|
|
iccid: "profile-b".to_string(),
|
|
imsi: String::new(),
|
|
operator_id: String::new(),
|
|
};
|
|
|
|
enrich_profiles_with_current_identity(&mut profiles, &identity);
|
|
|
|
assert_eq!(profiles[0].state, "disabled");
|
|
assert_eq!(profiles[1].state, "enabled");
|
|
}
|
|
|
|
#[test]
|
|
fn sorts_esim_profiles_with_enabled_first_and_stable_fallback() {
|
|
let mut profiles = vec![
|
|
EsimProfile {
|
|
iccid: "300".to_string(),
|
|
name: "Charlie".to_string(),
|
|
state: "disabled".to_string(),
|
|
..Default::default()
|
|
},
|
|
EsimProfile {
|
|
iccid: "100".to_string(),
|
|
name: "Alpha".to_string(),
|
|
state: "disabled".to_string(),
|
|
..Default::default()
|
|
},
|
|
EsimProfile {
|
|
iccid: "200".to_string(),
|
|
name: "Bravo".to_string(),
|
|
state: "enabled".to_string(),
|
|
..Default::default()
|
|
},
|
|
];
|
|
|
|
sort_esim_profiles_for_display(&mut profiles);
|
|
|
|
let order: Vec<&str> = profiles.iter().map(|profile| profile.iccid.as_str()).collect();
|
|
assert_eq!(order, vec!["200", "100", "300"]);
|
|
}
|
|
|
|
#[test]
|
|
fn splits_five_digit_operator_codes_for_profile_enrichment() {
|
|
assert_eq!(
|
|
split_profile_operator_code("46002"),
|
|
("460".to_string(), "02".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn labels_temperature_sensors_with_dashboard_names() {
|
|
assert_eq!(temperature_sensor_label("modem-thermal", ""), "基带");
|
|
assert_eq!(temperature_sensor_label("cpu0-1-thermal", ""), "CPU 0-1");
|
|
assert_eq!(temperature_sensor_label("core2_3_temp", ""), "核心 2-3");
|
|
assert_eq!(temperature_sensor_label("wifi_sensor", ""), "Wi-Fi");
|
|
}
|
|
}
|