Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "lox"
version = "0.10.0"
version = "0.10.1"
edition = "2024"
rust-version = "1.91"
description = "Loxone Miniserver CLI — control lights, blinds, and more from your terminal"
Expand Down
44 changes: 27 additions & 17 deletions src/commands/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,31 +124,41 @@ pub fn cmd_status(
let cpu_val = xml_attr(&cpu, "value").unwrap_or("?");
let tasks_val = xml_attr(&tasks, "value").unwrap_or("?");
let ctx_val = xml_attr(&ctx_sw, "value").unwrap_or("?");
let ints_val = xml_attr(&ints, "value").unwrap_or("?");
let comints_val = xml_attr(&comints, "value").unwrap_or("?");
let ctx_swi_val = xml_attr(&ctx_swi, "value").unwrap_or("?");
let ints_val = xml_attr(&ints, "value");
let comints_val = xml_attr(&comints, "value");
let ctx_swi_val = xml_attr(&ctx_swi, "value");
let sd_val = xml_attr(&sd, "value").unwrap_or("?");
let sd_info = parse_sdtest(sd_val);
if ctx.json {
println!(
"{}",
serde_json::json!({
"cpu": cpu_val, "tasks": tasks_val,
"context_switches": ctx_val,
"interrupts": ints_val,
"comm_interrupts": comints_val,
"context_switches_idle": ctx_swi_val,
"sd_card": sd_info.to_json(),
})
);
let mut diag = serde_json::json!({
"cpu": cpu_val, "tasks": tasks_val,
"context_switches": ctx_val,
"sd_card": sd_info.to_json(),
});
if let Some(v) = ints_val {
diag["interrupts"] = serde_json::json!(v);
}
if let Some(v) = comints_val {
diag["comm_interrupts"] = serde_json::json!(v);
}
if let Some(v) = ctx_swi_val {
diag["context_switches_idle"] = serde_json::json!(v);
}
println!("{}", diag);
} else {
println!("┌─ Diagnostics ───────────────────────────────────────");
println!("│ CPU: {}", cpu_val);
println!("│ Tasks: {}", tasks_val);
println!("│ Context switches: {}", ctx_val);
println!("│ Interrupts: {}", ints_val);
println!("│ Comm interrupts: {}", comints_val);
println!("│ Ctx switches (i): {}", ctx_swi_val);
if let Some(v) = ints_val {
println!("│ Interrupts: {}", v);
}
if let Some(v) = comints_val {
println!("│ Comm interrupts: {}", v);
}
if let Some(v) = ctx_swi_val {
println!("│ Ctx switches (i): {}", v);
}
sd_info.print_table();
println!("└─────────────────────────────────────────────────────");
}
Expand Down
125 changes: 121 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,22 @@ pub(crate) fn xml_attr<'a>(xml: &'a str, attr: &str) -> Option<&'a str> {
search_from = abs_pos + 1;
}

// JSON format: "attr": "value" (handles /jdev/ responses)
let json_key = format!("\"{}\": \"", attr);
// JSON format: "attr": value (handles /jdev/ responses)
let json_key = format!("\"{}\": ", attr);
if let Some(pos) = xml.find(&json_key) {
let start = pos + json_key.len();
let end = xml[start..].find('"')? + start;
return Some(&xml[start..end]);
let rest = &xml[start..];
if let Some(stripped) = rest.strip_prefix('"') {
// String value: extract between quotes
let end = stripped.find('"')?;
return Some(&stripped[..end]);
}
// Numeric/other value: read until delimiter
let end = rest.find([',', '}', ']']).unwrap_or(rest.len());
let val = rest[..end].trim();
if !val.is_empty() {
return Some(val);
}
}

None
Expand Down Expand Up @@ -2122,6 +2132,113 @@ mod tests {
assert_eq!(xml_attr(json, "value"), Some(""));
}

#[test]
fn test_xml_attr_json_numeric_value() {
// Gen2 Miniservers return numeric values without quotes for counters
let json =
r#"{"LL": { "control": "dev/sys/contextswitches", "value": 123456789, "Code": "200"}}"#;
assert_eq!(xml_attr(json, "value"), Some("123456789"));
assert_eq!(xml_attr(json, "Code"), Some("200"));
}

#[test]
fn test_xml_attr_json_numeric_last_field() {
// Numeric value as the last field before closing brace
let json = r#"{"LL": { "value": 42}}"#;
assert_eq!(xml_attr(json, "value"), Some("42"));
}

#[test]
fn test_xml_attr_json_float_value() {
let json = r#"{"LL": { "value": 3.14, "Code": "200"}}"#;
assert_eq!(xml_attr(json, "value"), Some("3.14"));
}

#[test]
fn test_xml_attr_json_negative_value() {
let json = r#"{"LL": { "value": -1, "Code": "200"}}"#;
assert_eq!(xml_attr(json, "value"), Some("-1"));
}

#[test]
fn test_xml_attr_json_zero_value() {
let json = r#"{"LL": { "value": 0, "Code": "200"}}"#;
assert_eq!(xml_attr(json, "value"), Some("0"));
}

#[test]
fn test_xml_attr_json_bool_value() {
// Unlikely from Miniserver but should not panic
let json = r#"{"LL": { "value": true, "Code": "200"}}"#;
assert_eq!(xml_attr(json, "value"), Some("true"));
}

#[test]
fn test_xml_attr_json_null_value() {
let json = r#"{"LL": { "value": null, "Code": "200"}}"#;
assert_eq!(xml_attr(json, "value"), Some("null"));
}

/// Exercises xml_attr against every response format the Miniserver uses
/// for the diagnostic endpoints exposed by `lox status --diag/--net/--bus/--lan`.
/// This is a regression test: Gen2 Miniservers return numeric JSON values
/// for counters, which the original string-only parser missed.
#[test]
fn test_xml_attr_all_diagnostic_response_formats() {
// Gen1 XML format (most endpoints)
let xml = r#"<LL control="dev/sys/heap" value="352880/1016404kB" Code="200"/>"#;
assert_eq!(xml_attr(xml, "value"), Some("352880/1016404kB"));

// Gen2 JSON with string value (lastcpu, numtasks, sdtest)
let json = r#"{"LL": { "control": "dev/sys/lastcpu", "value": "CPU:11 SPS:24 Cycles:99", "Code": "200"}}"#;
assert_eq!(xml_attr(json, "value"), Some("CPU:11 SPS:24 Cycles:99"));

// Gen2 JSON with numeric value (ints, comints, contextswitches, contextswitchesi)
for endpoint in [
"dev/sys/contextswitches",
"dev/sys/ints",
"dev/sys/comints",
"dev/sys/contextswitchesi",
] {
let json = format!(
r#"{{"LL": {{ "control": "{}", "value": 987654, "Code": "200"}}}}"#,
endpoint
);
assert_eq!(
xml_attr(&json, "value"),
Some("987654"),
"failed for endpoint {}",
endpoint
);
}

// Gen2 JSON with numeric value for bus/lan counters
for endpoint in [
"bus/packetssent",
"bus/packetsreceived",
"bus/parityerrors",
"lan/txp",
"lan/txu",
"lan/exh",
"lan/nob",
] {
let json = format!(
r#"{{"LL": {{ "control": "{}", "value": 42, "Code": "200"}}}}"#,
endpoint
);
assert_eq!(
xml_attr(&json, "value"),
Some("42"),
"failed for endpoint {}",
endpoint
);
}

// Gen2 JSON with string value for network config
let json = r#"{"LL": { "control": "cfg/dns2", "value": "8.8.4.4", "Code": "200"}}"#;
assert_eq!(xml_attr(json, "value"), Some("8.8.4.4"));
}

// ── matches_filters ───────────────────────────────────────────────────────

#[test]
Expand Down
64 changes: 62 additions & 2 deletions src/otel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -681,9 +681,15 @@ fn emit_automation_trace(
fn extract_lox_value(text: &str) -> Option<f64> {
// Try JSON first
if let Ok(v) = serde_json::from_str::<serde_json::Value>(text)
&& let Some(val) = v.pointer("/LL/value").and_then(|v| v.as_str())
&& let Some(val) = v.pointer("/LL/value")
{
return parse_numeric_prefix(val);
// Handle both string ("123") and numeric (123) JSON values
if let Some(n) = val.as_f64() {
return Some(n);
}
if let Some(s) = val.as_str() {
return parse_numeric_prefix(s);
}
}
// Try XML attribute
let key = "value=\"";
Expand Down Expand Up @@ -1159,6 +1165,13 @@ mod tests {
assert_eq!(extract_lox_value(text), Some(42.5));
}

#[test]
fn test_extract_lox_value_json_numeric() {
// Gen2 Miniservers return numeric values without quotes
let text = r#"{"LL":{"control":"dev/sys/contextswitches","value":123456789,"Code":"200"}}"#;
assert_eq!(extract_lox_value(text), Some(123456789.0));
}

#[test]
fn test_extract_lox_value_xml() {
let text = r#"<LL value="21.5" />"#;
Expand All @@ -1172,6 +1185,53 @@ mod tests {
assert_eq!(extract_lox_value(text), Some(352880.0));
}

#[test]
fn test_extract_lox_value_json_numeric_float() {
let text = r#"{"LL":{"value":3.14,"Code":"200"}}"#;
assert_eq!(extract_lox_value(text), Some(3.14));
}

#[test]
fn test_extract_lox_value_json_numeric_zero() {
let text = r#"{"LL":{"value":0,"Code":"200"}}"#;
assert_eq!(extract_lox_value(text), Some(0.0));
}

/// Regression: all OTLP diagnostic endpoints must extract values from
/// both Gen1 (XML, string) and Gen2 (JSON, numeric) response formats.
#[test]
fn test_extract_lox_value_all_diagnostic_formats() {
// Gen2 JSON numeric — the format that was broken
for (endpoint, num) in [
("dev/sys/ints", 987654),
("dev/sys/comints", 12345),
("dev/sys/contextswitchesi", 98765432),
("bus/parityerrors", 0),
("lan/txu", 42),
("lan/exh", 0),
("lan/nob", 7),
] {
let text = format!(
r#"{{"LL":{{"control":"{}","value":{},"Code":"200"}}}}"#,
endpoint, num
);
assert_eq!(
extract_lox_value(&text),
Some(num as f64),
"failed for endpoint {}",
endpoint
);
}

// Gen2 JSON string — CPU returns "CPU:12 SPS:26"
let text = r#"{"LL":{"value":"CPU:12 SPS:26","Code":"200"}}"#;
assert_eq!(extract_lox_value(text), None); // no leading number

// Gen2 JSON string with leading number — tasks
let text = r#"{"LL":{"value":"60","Code":"200"}}"#;
assert_eq!(extract_lox_value(text), Some(60.0));
}

#[test]
fn test_extract_lox_value_none() {
assert_eq!(extract_lox_value("no value here"), None);
Expand Down
Loading