internal data sources (clock, static text), connection indicator, rendering fixes
DataSourceConfig refactored to enum: External/Clock/StaticText. Clock generates formatted time via chrono, static text emits configured string. ESP32: connection status indicator (green/red dot bottom-right), per-widget clear before redraw, RenderEvent enum for local + server messages. Polling uses DataUpdate instead of ScreenUpdate to avoid wiping widget state. Empty mappings passthrough raw source data for internal sources.
This commit is contained in:
@@ -18,6 +18,8 @@ pub fn data_source_type_to_str(t: &DataSourceType) -> &'static str {
|
||||
DataSourceType::Rss => "rss",
|
||||
DataSourceType::HttpJson => "http_json",
|
||||
DataSourceType::Webhook => "webhook",
|
||||
DataSourceType::Clock => "clock",
|
||||
DataSourceType::StaticText => "static_text",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +30,8 @@ fn data_source_type_from_str(s: &str) -> Result<DataSourceType, SqliteConfigErro
|
||||
"rss" => Ok(DataSourceType::Rss),
|
||||
"http_json" => Ok(DataSourceType::HttpJson),
|
||||
"webhook" => Ok(DataSourceType::Webhook),
|
||||
"clock" => Ok(DataSourceType::Clock),
|
||||
"static_text" => Ok(DataSourceType::StaticText),
|
||||
_ => Err(SqliteConfigError::Serialization(format!(
|
||||
"unknown source type: {s}"
|
||||
))),
|
||||
@@ -38,33 +42,54 @@ pub fn data_source_config_to_json(
|
||||
config: &DataSourceConfig,
|
||||
secrets: Option<&(dyn SecretStore + Send + Sync)>,
|
||||
) -> Result<String, SqliteConfigError> {
|
||||
let api_key = config.api_key.as_ref().map(|k| match secrets {
|
||||
Some(s) => s.encrypt(k),
|
||||
None => k.clone(),
|
||||
});
|
||||
let v = match config {
|
||||
DataSourceConfig::External {
|
||||
url,
|
||||
headers,
|
||||
api_key,
|
||||
} => {
|
||||
let api_key = api_key.as_ref().map(|k| match secrets {
|
||||
Some(s) => s.encrypt(k),
|
||||
None => k.clone(),
|
||||
});
|
||||
|
||||
let headers: Vec<(String, String)> = config
|
||||
.headers
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let val = if is_sensitive_key(k) {
|
||||
match secrets {
|
||||
Some(s) => s.encrypt(v),
|
||||
None => v.clone(),
|
||||
}
|
||||
} else {
|
||||
v.clone()
|
||||
};
|
||||
(k.clone(), val)
|
||||
})
|
||||
.collect();
|
||||
let headers: Vec<(String, String)> = headers
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let val = if is_sensitive_key(k) {
|
||||
match secrets {
|
||||
Some(s) => s.encrypt(v),
|
||||
None => v.clone(),
|
||||
}
|
||||
} else {
|
||||
v.clone()
|
||||
};
|
||||
(k.clone(), val)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let v = serde_json::json!({
|
||||
"url": config.url,
|
||||
"headers": headers,
|
||||
"api_key": api_key,
|
||||
"encrypted": secrets.is_some(),
|
||||
});
|
||||
serde_json::json!({
|
||||
"type": "external",
|
||||
"url": url,
|
||||
"headers": headers,
|
||||
"api_key": api_key,
|
||||
"encrypted": secrets.is_some(),
|
||||
})
|
||||
}
|
||||
DataSourceConfig::Clock { format, timezone } => {
|
||||
serde_json::json!({
|
||||
"type": "clock",
|
||||
"format": format,
|
||||
"timezone": timezone,
|
||||
})
|
||||
}
|
||||
DataSourceConfig::StaticText { text } => {
|
||||
serde_json::json!({
|
||||
"type": "static_text",
|
||||
"text": text,
|
||||
})
|
||||
}
|
||||
};
|
||||
serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -75,47 +100,61 @@ fn data_source_config_from_json(
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(json).map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||
|
||||
let encrypted = v["encrypted"].as_bool().unwrap_or(false);
|
||||
let config_type = v["type"].as_str().unwrap_or("external");
|
||||
|
||||
let url = v["url"].as_str().map(String::from);
|
||||
|
||||
let api_key = v["api_key"].as_str().map(|k| {
|
||||
if encrypted {
|
||||
match secrets {
|
||||
Some(s) => s.decrypt(k),
|
||||
None => k.to_string(),
|
||||
}
|
||||
} else {
|
||||
k.to_string()
|
||||
match config_type {
|
||||
"clock" => {
|
||||
let format = v["format"].as_str().unwrap_or("%H:%M:%S").to_string();
|
||||
let timezone = v["timezone"].as_str().unwrap_or("UTC").to_string();
|
||||
Ok(DataSourceConfig::Clock { format, timezone })
|
||||
}
|
||||
});
|
||||
"static_text" => {
|
||||
let text = v["text"].as_str().unwrap_or("").to_string();
|
||||
Ok(DataSourceConfig::StaticText { text })
|
||||
}
|
||||
_ => {
|
||||
let encrypted = v["encrypted"].as_bool().unwrap_or(false);
|
||||
let url = v["url"].as_str().map(String::from);
|
||||
|
||||
let headers = match v["headers"].as_array() {
|
||||
Some(arr) => arr
|
||||
.iter()
|
||||
.filter_map(|h| {
|
||||
let pair = h.as_array()?;
|
||||
let key: String = pair[0].as_str()?.into();
|
||||
let raw_val: &str = pair[1].as_str()?;
|
||||
let val = if encrypted && is_sensitive_key(&key) {
|
||||
let api_key = v["api_key"].as_str().map(|k| {
|
||||
if encrypted {
|
||||
match secrets {
|
||||
Some(s) => s.decrypt(raw_val),
|
||||
None => raw_val.to_string(),
|
||||
Some(s) => s.decrypt(k),
|
||||
None => k.to_string(),
|
||||
}
|
||||
} else {
|
||||
raw_val.to_string()
|
||||
};
|
||||
Some((key, val))
|
||||
})
|
||||
.collect(),
|
||||
None => vec![],
|
||||
};
|
||||
k.to_string()
|
||||
}
|
||||
});
|
||||
|
||||
Ok(DataSourceConfig {
|
||||
url,
|
||||
headers,
|
||||
api_key,
|
||||
})
|
||||
let headers = match v["headers"].as_array() {
|
||||
Some(arr) => arr
|
||||
.iter()
|
||||
.filter_map(|h| {
|
||||
let pair = h.as_array()?;
|
||||
let key: String = pair[0].as_str()?.into();
|
||||
let raw_val: &str = pair[1].as_str()?;
|
||||
let val = if encrypted && is_sensitive_key(&key) {
|
||||
match secrets {
|
||||
Some(s) => s.decrypt(raw_val),
|
||||
None => raw_val.to_string(),
|
||||
}
|
||||
} else {
|
||||
raw_val.to_string()
|
||||
};
|
||||
Some((key, val))
|
||||
})
|
||||
.collect(),
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
Ok(DataSourceConfig::External {
|
||||
url,
|
||||
headers,
|
||||
api_key,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn data_source_from_row(
|
||||
|
||||
@@ -36,7 +36,7 @@ fn weather_source() -> DataSource {
|
||||
name: "openweather".into(),
|
||||
source_type: DataSourceType::Weather,
|
||||
poll_interval: Duration::from_secs(300),
|
||||
config: DataSourceConfig {
|
||||
config: DataSourceConfig::External {
|
||||
url: Some("https://api.openweather.org".into()),
|
||||
headers: vec![],
|
||||
api_key: Some("test-key".into()),
|
||||
@@ -125,8 +125,13 @@ async fn save_and_retrieve_data_source() {
|
||||
assert_eq!(ds.name, "openweather");
|
||||
assert_eq!(ds.source_type, DataSourceType::Weather);
|
||||
assert_eq!(ds.poll_interval, Duration::from_secs(300));
|
||||
assert_eq!(ds.config.url, Some("https://api.openweather.org".into()));
|
||||
assert_eq!(ds.config.api_key, Some("test-key".into()));
|
||||
match &ds.config {
|
||||
DataSourceConfig::External { url, api_key, .. } => {
|
||||
assert_eq!(*url, Some("https://api.openweather.org".into()));
|
||||
assert_eq!(*api_key, Some("test-key".into()));
|
||||
}
|
||||
_ => panic!("expected External config"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -171,9 +171,7 @@ async fn create_and_get_data_source() {
|
||||
"name": "weather_api",
|
||||
"source_type": "weather",
|
||||
"poll_interval_secs": 300,
|
||||
"url": "https://api.openweather.org",
|
||||
"api_key": "test-key",
|
||||
"headers": []
|
||||
"config": {"type": "external", "url": "https://api.openweather.org", "api_key": "test-key", "headers": []}
|
||||
}"#;
|
||||
|
||||
let resp = app
|
||||
|
||||
@@ -47,15 +47,23 @@ impl DataSourcePort for HttpJsonAdapter {
|
||||
type Error = HttpJsonError;
|
||||
|
||||
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
|
||||
let url = source.config.url.as_ref().ok_or(HttpJsonError::NoUrl)?;
|
||||
let domain::DataSourceConfig::External {
|
||||
ref url,
|
||||
ref headers,
|
||||
ref api_key,
|
||||
} = source.config
|
||||
else {
|
||||
return Err(HttpJsonError::NoUrl);
|
||||
};
|
||||
let url = url.as_ref().ok_or(HttpJsonError::NoUrl)?;
|
||||
|
||||
let mut req = self.client.get(url);
|
||||
|
||||
for (key, val) in &source.config.headers {
|
||||
for (key, val) in headers {
|
||||
req = req.header(key, val);
|
||||
}
|
||||
|
||||
if let Some(api_key) = &source.config.api_key {
|
||||
if let Some(api_key) = api_key {
|
||||
req = req.header("Authorization", format!("Bearer {api_key}"));
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ fn make_source(url: String) -> DataSource {
|
||||
name: "test".into(),
|
||||
source_type: DataSourceType::HttpJson,
|
||||
poll_interval: Duration::from_secs(60),
|
||||
config: DataSourceConfig {
|
||||
config: DataSourceConfig::External {
|
||||
url: Some(url),
|
||||
headers: vec![],
|
||||
api_key: None,
|
||||
@@ -82,7 +82,7 @@ async fn returns_error_when_no_url() {
|
||||
name: "bad".into(),
|
||||
source_type: DataSourceType::HttpJson,
|
||||
poll_interval: Duration::from_secs(60),
|
||||
config: DataSourceConfig {
|
||||
config: DataSourceConfig::External {
|
||||
url: None,
|
||||
headers: vec![],
|
||||
api_key: None,
|
||||
|
||||
@@ -38,11 +38,19 @@ impl DataSourcePort for MediaAdapter {
|
||||
type Error = MediaError;
|
||||
|
||||
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
|
||||
let base_url = source.config.url.as_ref().ok_or(MediaError::NoUrl)?;
|
||||
let username = find_header(&source.config.headers, "username")
|
||||
.ok_or(MediaError::MissingField("username"))?;
|
||||
let password = find_header(&source.config.headers, "password")
|
||||
.ok_or(MediaError::MissingField("password"))?;
|
||||
let domain::DataSourceConfig::External {
|
||||
ref url,
|
||||
ref headers,
|
||||
..
|
||||
} = source.config
|
||||
else {
|
||||
return Err(MediaError::NoUrl);
|
||||
};
|
||||
let base_url = url.as_ref().ok_or(MediaError::NoUrl)?;
|
||||
let username =
|
||||
find_header(headers, "username").ok_or(MediaError::MissingField("username"))?;
|
||||
let password =
|
||||
find_header(headers, "password").ok_or(MediaError::MissingField("password"))?;
|
||||
|
||||
let salt: String = (0..12).map(|_| fastrand::alphanumeric()).collect();
|
||||
let token = subsonic_token(password, &salt);
|
||||
|
||||
@@ -45,7 +45,7 @@ fn make_source(url: String) -> DataSource {
|
||||
name: "navidrome".into(),
|
||||
source_type: DataSourceType::Media,
|
||||
poll_interval: Duration::from_secs(5),
|
||||
config: DataSourceConfig {
|
||||
config: DataSourceConfig::External {
|
||||
url: Some(url),
|
||||
headers: vec![
|
||||
("username".into(), "test".into()),
|
||||
|
||||
@@ -28,7 +28,10 @@ impl DataSourcePort for RssAdapter {
|
||||
type Error = RssError;
|
||||
|
||||
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
|
||||
let url = source.config.url.as_ref().ok_or(RssError::NoUrl)?;
|
||||
let domain::DataSourceConfig::External { ref url, .. } = source.config else {
|
||||
return Err(RssError::NoUrl);
|
||||
};
|
||||
let url = url.as_ref().ok_or(RssError::NoUrl)?;
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
|
||||
Reference in New Issue
Block a user