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