Bootstrap DD3 Rust port workspace with host-first compatibility tests
This commit is contained in:
6
crates/dd3_contracts/Cargo.toml
Normal file
6
crates/dd3_contracts/Cargo.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "dd3_contracts"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
69
crates/dd3_contracts/src/csv.rs
Normal file
69
crates/dd3_contracts/src/csv.rs
Normal 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
|
||||
}
|
||||
34
crates/dd3_contracts/src/escape.rs
Normal file
34
crates/dd3_contracts/src/escape.rs
Normal 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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
_ => 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
|
||||
}
|
||||
63
crates/dd3_contracts/src/ha.rs
Normal file
63
crates/dd3_contracts/src/ha.rs
Normal 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
|
||||
}
|
||||
13
crates/dd3_contracts/src/lib.rs
Normal file
13
crates/dd3_contracts/src/lib.rs
Normal 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";
|
||||
135
crates/dd3_contracts/src/mqtt.rs
Normal file
135
crates/dd3_contracts/src/mqtt.rs
Normal 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(","))
|
||||
}
|
||||
48
crates/dd3_contracts/src/sanitize.rs
Normal file
48
crates/dd3_contracts/src/sanitize.rs
Normal 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)
|
||||
}
|
||||
184
crates/dd3_contracts/tests/contracts_tests.rs
Normal file
184
crates/dd3_contracts/tests/contracts_tests.rs
Normal 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&b", html_escape("a&b"));
|
||||
assert_eq!("<tag>", html_escape("<tag>"));
|
||||
assert_eq!(""hi"", html_escape("\"hi\""));
|
||||
assert_eq!("it's", html_escape("it's"));
|
||||
assert_eq!("&<>"'", html_escape("&<>\"'"));
|
||||
assert_eq!("&amp;", html_escape("&"));
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user