diff --git a/Cargo.lock b/Cargo.lock index 4758fc4..58d5a81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1615,7 +1615,7 @@ dependencies = [ [[package]] name = "lox" -version = "0.10.0" +version = "0.10.1" dependencies = [ "aes", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index b64acd9..850b52c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/commands/system.rs b/src/commands/system.rs index 27481c4..497b2c9 100644 --- a/src/commands/system.rs +++ b/src/commands/system.rs @@ -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!("└─────────────────────────────────────────────────────"); } diff --git a/src/main.rs b/src/main.rs index 9264279..c3d3c59 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 @@ -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#""#; + 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] diff --git a/src/otel.rs b/src/otel.rs index 19c2454..cc6cd84 100644 --- a/src/otel.rs +++ b/src/otel.rs @@ -681,9 +681,15 @@ fn emit_automation_trace( fn extract_lox_value(text: &str) -> Option { // Try JSON first if let Ok(v) = serde_json::from_str::(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=\""; @@ -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#""#; @@ -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);