Bootstrap DD3 Rust port workspace with host-first compatibility tests

This commit is contained in:
2026-02-21 00:59:03 +01:00
parent d3f9a2e62d
commit d0212f4e38
63 changed files with 3914 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
[package]
name = "dd3_contracts"
version = "0.1.0"
edition = "2021"
[dependencies]

View File

@@ -0,0 +1,69 @@
use std::string::String;
pub const CSV_HEADER: &str =
"ts_utc,ts_hms_local,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last";
fn round_to(value: f32, decimals: u32) -> f32 {
let scale = 10f32.powi(decimals as i32);
(value * scale).round() / scale
}
#[derive(Debug, Clone, PartialEq)]
pub struct CsvLineInput<'a> {
pub ts_utc: u32,
pub ts_hms_local: &'a str,
pub p_w: f32,
pub p1_w: f32,
pub p2_w: f32,
pub p3_w: f32,
pub e_kwh: f32,
pub bat_v: f32,
pub bat_pct: u8,
pub rssi: i16,
pub snr: f32,
pub err_m: u32,
pub err_d: u32,
pub err_tx: u32,
pub err_last_text: Option<&'a str>,
pub include_error_text: bool,
}
pub fn format_csv_line(input: &CsvLineInput<'_>) -> String {
let mut line = String::new();
line.push_str(&input.ts_utc.to_string());
line.push(',');
line.push_str(input.ts_hms_local);
line.push(',');
line.push_str(&format!("{:.1}", round_to(input.p_w, 1)));
line.push(',');
line.push_str(&format!("{:.1}", round_to(input.p1_w, 1)));
line.push(',');
line.push_str(&format!("{:.1}", round_to(input.p2_w, 1)));
line.push(',');
line.push_str(&format!("{:.1}", round_to(input.p3_w, 1)));
line.push(',');
line.push_str(&format!("{:.3}", round_to(input.e_kwh, 3)));
line.push(',');
line.push_str(&format!("{:.2}", round_to(input.bat_v, 2)));
line.push(',');
line.push_str(&input.bat_pct.to_string());
line.push(',');
line.push_str(&input.rssi.to_string());
line.push(',');
if !input.snr.is_nan() {
line.push_str(&format!("{:.1}", round_to(input.snr, 1)));
}
line.push(',');
line.push_str(&input.err_m.to_string());
line.push(',');
line.push_str(&input.err_d.to_string());
line.push(',');
line.push_str(&input.err_tx.to_string());
line.push(',');
if input.include_error_text {
if let Some(err_text) = input.err_last_text {
line.push_str(err_text);
}
}
line
}

View File

@@ -0,0 +1,34 @@
use std::string::String;
pub fn html_escape(input: &str) -> String {
let mut out = String::with_capacity(input.len() + 8);
for ch in input.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
_ => out.push(ch),
}
}
out
}
pub fn url_encode_component(input: &str) -> String {
let mut out = String::with_capacity(input.len() * 3);
const HEX: &[u8; 16] = b"0123456789ABCDEF";
for b in input.as_bytes() {
let safe = b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_' || *b == b'.' || *b == b'~';
if safe {
out.push(*b as char);
} else {
out.push('%');
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0F) as usize] as char);
}
}
out
}

View File

@@ -0,0 +1,63 @@
use std::string::String;
use crate::HA_MANUFACTURER;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HaDiscoverySpec<'a> {
pub device_id: &'a str,
pub key: &'a str,
pub name: &'a str,
pub unit: Option<&'a str>,
pub device_class: Option<&'a str>,
pub state_topic: &'a str,
pub value_template: &'a str,
}
fn json_escape(input: &str) -> String {
let mut out = String::with_capacity(input.len() + 8);
for c in input.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
ch if (ch as u32) < 0x20 => out.push_str(&format!("\\u{:04X}", ch as u32)),
_ => out.push(c),
}
}
out
}
pub fn build_ha_discovery_topic(device_id: &str, key: &str) -> String {
format!("homeassistant/sensor/{}/{}/config", device_id, key)
}
pub fn build_ha_discovery_payload(spec: &HaDiscoverySpec<'_>) -> String {
let sensor_name = format!("{} {}", spec.device_id, spec.name);
let unique_id = format!("{}_{}", spec.device_id, spec.key);
let mut json = String::new();
json.push('{');
json.push_str(&format!("\"name\":\"{}\"", json_escape(&sensor_name)));
json.push_str(&format!(",\"state_topic\":\"{}\"", json_escape(spec.state_topic)));
json.push_str(&format!(",\"unique_id\":\"{}\"", json_escape(&unique_id)));
if let Some(unit) = spec.unit {
if !unit.is_empty() {
json.push_str(&format!(",\"unit_of_measurement\":\"{}\"", json_escape(unit)));
}
}
if let Some(dc) = spec.device_class {
if !dc.is_empty() {
json.push_str(&format!(",\"device_class\":\"{}\"", json_escape(dc)));
}
}
json.push_str(&format!(",\"value_template\":\"{}\"", json_escape(spec.value_template)));
json.push_str(",\"device\":{");
json.push_str(&format!("\"identifiers\":[\"{}\"]", json_escape(spec.device_id)));
json.push_str(&format!(",\"name\":\"{}\"", json_escape(spec.device_id)));
json.push_str(",\"model\":\"DD3-LoRa-Bridge\"");
json.push_str(&format!(",\"manufacturer\":\"{}\"", json_escape(HA_MANUFACTURER)));
json.push_str("}}");
json
}

View File

@@ -0,0 +1,13 @@
pub mod csv;
pub mod escape;
pub mod ha;
pub mod mqtt;
pub mod sanitize;
pub use csv::{format_csv_line, CsvLineInput, CSV_HEADER};
pub use escape::{html_escape, url_encode_component};
pub use ha::{build_ha_discovery_payload, build_ha_discovery_topic, HaDiscoverySpec};
pub use mqtt::{mqtt_state_json, rx_reject_reason_text, MqttState};
pub use sanitize::{sanitize_device_id, SanitizeError};
pub const HA_MANUFACTURER: &str = "AcidBurns";

View File

@@ -0,0 +1,135 @@
use std::string::String;
#[derive(Debug, Clone, PartialEq)]
pub struct MqttState<'a> {
pub device_id: &'a str,
pub ts_utc: u32,
pub energy_total_kwh: f32,
pub total_power_w: f32,
pub phase_power_w: [f32; 3],
pub battery_voltage_v: f32,
pub battery_percent: u8,
pub link_valid: bool,
pub link_rssi_dbm: i16,
pub link_snr_db: f32,
pub err_meter_read: u32,
pub err_decode: u32,
pub err_lora_tx: u32,
pub err_last: u8,
pub rx_reject_reason: u8,
}
fn json_escape(input: &str) -> String {
let mut out = String::with_capacity(input.len() + 8);
for c in input.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
ch if (ch as u32) < 0x20 => out.push_str(&format!("\\u{:04X}", ch as u32)),
_ => out.push(c),
}
}
out
}
fn round2(v: f32) -> f32 {
if v.is_nan() {
return v;
}
(v * 100.0).round() / 100.0
}
fn format_float_2_json(v: f32) -> String {
if v.is_nan() {
return "null".to_string();
}
format!("{:.2}", round2(v))
}
fn round_to_i32(value: f32) -> i32 {
if value.is_nan() {
return 0;
}
let rounded = value.round();
if rounded > i32::MAX as f32 {
i32::MAX
} else if rounded < i32::MIN as f32 {
i32::MIN
} else {
rounded as i32
}
}
fn short_id_from_device_id(device_id: &str) -> &str {
if device_id.len() >= 4 {
&device_id[device_id.len() - 4..]
} else {
device_id
}
}
pub fn rx_reject_reason_text(reason: u8) -> &'static str {
match reason {
1 => "crc_fail",
2 => "invalid_msg_kind",
3 => "length_mismatch",
4 => "device_id_mismatch",
5 => "batch_id_mismatch",
6 => "unknown_sender",
_ => "none",
}
}
pub fn mqtt_state_json(state: &MqttState<'_>) -> String {
let mut parts: Vec<String> = Vec::new();
parts.push(format!("\"id\":\"{}\"", json_escape(short_id_from_device_id(state.device_id))));
parts.push(format!("\"ts\":{}", state.ts_utc));
parts.push(format!("\"e_kwh\":{}", format_float_2_json(state.energy_total_kwh)));
if state.total_power_w.is_nan() {
parts.push("\"p_w\":null".to_string());
} else {
parts.push(format!("\"p_w\":{}", round_to_i32(state.total_power_w)));
}
for (key, value) in [
("p1_w", state.phase_power_w[0]),
("p2_w", state.phase_power_w[1]),
("p3_w", state.phase_power_w[2]),
] {
if value.is_nan() {
parts.push(format!("\"{}\":null", key));
} else {
parts.push(format!("\"{}\":{}", key, round_to_i32(value)));
}
}
parts.push(format!("\"bat_v\":{}", format_float_2_json(state.battery_voltage_v)));
parts.push(format!("\"bat_pct\":{}", state.battery_percent));
if state.link_valid {
parts.push(format!("\"rssi\":{}", state.link_rssi_dbm));
parts.push(format!("\"snr\":{}", state.link_snr_db));
}
if state.err_meter_read > 0 {
parts.push(format!("\"err_m\":{}", state.err_meter_read));
}
if state.err_decode > 0 {
parts.push(format!("\"err_d\":{}", state.err_decode));
}
if state.err_lora_tx > 0 {
parts.push(format!("\"err_tx\":{}", state.err_lora_tx));
}
parts.push(format!("\"err_last\":{}", state.err_last));
parts.push(format!("\"rx_reject\":{}", state.rx_reject_reason));
parts.push(format!(
"\"rx_reject_text\":\"{}\"",
rx_reject_reason_text(state.rx_reject_reason)
));
format!("{{{}}}", parts.join(","))
}

View File

@@ -0,0 +1,48 @@
use std::string::String;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SanitizeError {
Empty,
PathTraversal,
PercentNotAllowed,
InvalidFormat,
InvalidHex,
}
fn is_hex_char(c: char) -> bool {
c.is_ascii_hexdigit()
}
fn to_upper_hex4(input: &str) -> String {
input.to_ascii_uppercase()
}
pub fn sanitize_device_id(input: &str) -> Result<String, SanitizeError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(SanitizeError::Empty);
}
if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") {
return Err(SanitizeError::PathTraversal);
}
if trimmed.contains('%') {
return Err(SanitizeError::PercentNotAllowed);
}
if trimmed.len() == 4 {
if !trimmed.chars().all(is_hex_char) {
return Err(SanitizeError::InvalidHex);
}
return Ok(format!("dd3-{}", to_upper_hex4(trimmed)));
}
if trimmed.len() == 8 && trimmed.starts_with("dd3-") {
let hex = &trimmed[4..];
if !hex.chars().all(is_hex_char) {
return Err(SanitizeError::InvalidHex);
}
return Ok(format!("dd3-{}", to_upper_hex4(hex)));
}
Err(SanitizeError::InvalidFormat)
}

View File

@@ -0,0 +1,184 @@
use std::fs;
use std::path::Path;
use dd3_contracts::{
build_ha_discovery_payload, format_csv_line, html_escape, mqtt_state_json, sanitize_device_id,
url_encode_component, CsvLineInput, HaDiscoverySpec, MqttState, CSV_HEADER, HA_MANUFACTURER,
};
fn fixture(path: &str) -> String {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let full = root.join("../../").join(path);
fs::read_to_string(full).unwrap()
}
#[test]
fn ha_discovery_snapshot_and_manufacturer_lock() {
let spec = HaDiscoverySpec {
device_id: "dd3-F19C",
key: "energy",
name: "Energy",
unit: Some("kWh"),
device_class: Some("energy"),
state_topic: "smartmeter/dd3-F19C/state",
value_template: "{{ value_json.e_kwh }}",
};
let actual = build_ha_discovery_payload(&spec);
let expected = fixture("fixtures/contracts/ha_discovery/energy.json");
assert_eq!(expected, actual);
assert!(actual.contains("\"manufacturer\":\"AcidBurns\""));
}
#[test]
fn mqtt_state_snapshot_required_keys_and_no_legacy_keys() {
let state = MqttState {
device_id: "dd3-F19C",
ts_utc: 1_769_905_000,
energy_total_kwh: 1234.5678,
total_power_w: 321.6,
phase_power_w: [100.4, 110.4, 110.8],
battery_voltage_v: 3.876,
battery_percent: 77,
link_valid: true,
link_rssi_dbm: -71,
link_snr_db: 7.25,
err_meter_read: 1,
err_decode: 2,
err_lora_tx: 3,
err_last: 2,
rx_reject_reason: 1,
};
let actual = mqtt_state_json(&state);
let expected = fixture("fixtures/contracts/mqtt_state/sample.json");
assert_eq!(expected, actual);
for key in [
"\"id\"", "\"ts\"", "\"e_kwh\"", "\"p_w\"", "\"p1_w\"", "\"p2_w\"", "\"p3_w\"",
"\"bat_v\"", "\"bat_pct\"", "\"rssi\"", "\"snr\"", "\"err_m\"", "\"err_d\"",
"\"err_tx\"", "\"err_last\"", "\"rx_reject\"", "\"rx_reject_text\"",
] {
assert!(actual.contains(key), "missing key {key}");
}
assert!(!actual.contains("energy_total_kwh"));
assert!(!actual.contains("power_w"));
assert!(!actual.contains("battery_voltage"));
}
#[test]
fn mqtt_state_optional_fields_omitted_when_unavailable() {
let state = MqttState {
device_id: "dd3-F19C",
ts_utc: 1_769_905_000,
energy_total_kwh: 10.0,
total_power_w: 100.0,
phase_power_w: [30.0, 30.0, 40.0],
battery_voltage_v: 3.9,
battery_percent: 88,
link_valid: false,
link_rssi_dbm: -80,
link_snr_db: 3.2,
err_meter_read: 0,
err_decode: 0,
err_lora_tx: 0,
err_last: 0,
rx_reject_reason: 0,
};
let json = mqtt_state_json(&state);
assert!(!json.contains("\"rssi\""));
assert!(!json.contains("\"snr\""));
assert!(!json.contains("\"err_m\""));
assert!(!json.contains("\"err_d\""));
assert!(!json.contains("\"err_tx\""));
assert!(json.contains("\"rx_reject_text\":\"none\""));
}
#[test]
fn csv_header_and_line_snapshot() {
assert_eq!(
"ts_utc,ts_hms_local,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last",
CSV_HEADER
);
let line = format_csv_line(&CsvLineInput {
ts_utc: 1_769_905_000,
ts_hms_local: "12:34:56",
p_w: 321.6,
p1_w: 100.4,
p2_w: 110.4,
p3_w: 110.8,
e_kwh: 1234.5678,
bat_v: 3.876,
bat_pct: 77,
rssi: -71,
snr: 7.25,
err_m: 1,
err_d: 2,
err_tx: 3,
err_last_text: Some("decode"),
include_error_text: true,
});
let snapshot = fixture("fixtures/contracts/sd_csv/sample.csv");
let expected_line = snapshot.lines().nth(1).unwrap();
assert_eq!(expected_line, line);
}
#[test]
fn html_url_and_sanitize_table_cases() {
assert_eq!("", html_escape(""));
assert_eq!("plain", html_escape("plain"));
assert_eq!("a&amp;b", html_escape("a&b"));
assert_eq!("&lt;tag&gt;", html_escape("<tag>"));
assert_eq!("&quot;hi&quot;", html_escape("\"hi\""));
assert_eq!("it&#39;s", html_escape("it's"));
assert_eq!("&amp;&lt;&gt;&quot;&#39;", html_escape("&<>\"'"));
assert_eq!("&amp;amp;", html_escape("&amp;"));
assert_eq!("", url_encode_component(""));
assert_eq!("abcABC012-_.~", url_encode_component("abcABC012-_.~"));
assert_eq!("a%20b", url_encode_component("a b"));
assert_eq!("%2F%5C%3F%26%23%25%22%27", url_encode_component("/\\?&#%\"'"));
assert_eq!("line%0Abreak", url_encode_component("line\nbreak"));
assert_eq!("%01%1F%7F", url_encode_component(&String::from_utf8(vec![1, 31, 127]).unwrap()));
for accept in ["F19C", "f19c", " f19c ", "dd3-f19c", "dd3-F19C", "dd3-a0b1"] {
let out = sanitize_device_id(accept).unwrap();
if accept.contains("a0b1") {
assert_eq!("dd3-A0B1", out);
} else {
assert_eq!("dd3-F19C", out);
}
}
for reject in [
"", "F", "FFF", "FFFFF", "dd3-12", "dd3-12345", "F1 9C", "dd3-F1\t9C",
"dd3-F19C%00", "%F19C", "../F19C", "dd3-..1A", "dd3-12/3", "dd3-12\\3", "F19G", "dd3-zzzz",
] {
assert!(sanitize_device_id(reject).is_err(), "unexpected accept: {reject}");
}
}
#[test]
fn manufacturer_drift_guard() {
assert_eq!("AcidBurns", HA_MANUFACTURER);
let roots = [Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
for root in roots {
let entries = fs::read_dir(&root).unwrap();
for entry in entries {
let path = entry.unwrap().path();
if path.extension().and_then(|x| x.to_str()) != Some("rs") {
continue;
}
let txt = fs::read_to_string(&path).unwrap();
if path.ends_with(Path::new("lib.rs")) {
continue;
}
assert!(!txt.contains("\"AcidBurns\""), "hardcoded manufacturer in {}", path.display());
}
}
}