Skip to main content

esphome_native_api/
hash.rs

1//! Hash utilities for generating stable 32-bit identifiers.
2//!
3//! This module implements a 32-bit FNV-1 hash function with specific preprocessing
4//! steps just like the ESPHome native API uses for generating entity keys from object IDs.
5
6const FNV1_OFFSET_BASIS: u32 = 2166136261;
7const FNV1_PRIME: u32 = 16777619;
8
9fn to_snake_case_char(c: char) -> char {
10    if c == ' ' {
11        '_'
12    } else if c >= 'A' && c <= 'Z' {
13        ((c as u8) + (b'a' - b'A')) as char
14    } else {
15        c
16    }
17}
18
19fn to_sanitized_char(c: char) -> char {
20    // Keep alphanumerics, dashes, underscores; replace others with underscore
21    if c == '-'
22        || c == '_'
23        || (c >= '0' && c <= '9')
24        || (c >= 'a' && c <= 'z')
25        || (c >= 'A' && c <= 'Z')
26    {
27        c
28    } else {
29        '_'
30    }
31}
32
33/// Compute the 32-bit FNV-1 hash of a name after applying a
34/// snake-case and sanitization pass.
35///
36/// # Examples
37///
38/// ```
39/// use esphome_native_api::hash::hash_fnv1;
40///
41/// // Basic string
42/// let s = "foo".to_string();
43/// assert_eq!(hash_fnv1(&s), 0x408F5E13);
44/// ```
45pub fn hash_fnv1(name: &String) -> u32 {
46    let mut hash = FNV1_OFFSET_BASIS;
47    for c in name.chars() {
48        hash = hash.wrapping_mul(FNV1_PRIME);
49        let processed_char = to_sanitized_char(to_snake_case_char(c));
50        hash ^= processed_char as u8 as u32;
51    }
52    hash
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    macro_rules! fnv1_hash_tests {
60        ($($name:ident: $input:expr => $expected:expr;)*) => {
61            $(
62                #[test]
63                fn $name() {
64                    let actual = hash_fnv1(&$input.to_string());
65                    assert_eq!(
66                        actual, $expected,
67                        "Hash mismatch for '{}': expected {:#x}, got {:#x}",
68                        $input, $expected, actual
69                    );
70                }
71            )*
72        };
73    }
74
75    fnv1_hash_tests! {
76        // Basic strings - hash of sanitize(snake_case(name))
77        test_hash_foo: "foo" => 0x408F5E13u32;
78        test_hash_foo_uppercase: "Foo" => 0x408F5E13u32; // Same as "foo" (lowercase)
79        test_hash_foo_all_caps: "FOO" => 0x408F5E13u32; // Same as "foo" (lowercase)
80        // Spaces become underscores
81        test_hash_foo_bar_space: "foo bar" => 0x3AE35AA1u32; // transforms to "foo_bar"
82        test_hash_foo_bar_space_caps: "Foo Bar" => 0x3AE35AA1u32; // Same (lowercase + underscore)
83        // Already snake_case
84        test_hash_foo_bar_underscore: "foo_bar" => 0x3AE35AA1u32;
85        // Special chars become underscores
86        test_hash_foo_bar_exclamation: "foo!bar" => 0x3AE35AA1u32; // Transforms to "foo_bar"
87        test_hash_foo_bar_at: "foo@bar" => 0x3AE35AA1u32; // Transforms to "foo_bar"
88        // Hyphens are preserved
89        test_hash_foo_bar_hyphen: "foo-bar" => 0x438B12E3u32;
90        // Numbers are preserved
91        test_hash_foo123: "foo123" => 0xF3B0067Du32;
92        // Empty string
93        test_hash_empty: "" => 0x811C9DC5u32; // FNV1_OFFSET_BASIS (no chars processed)
94        // Single char
95        test_hash_single_char: "a" => 0x050C5D7Eu32;
96        // Mixed case and spaces
97        test_hash_my_sensor_name: "My Sensor Name" => 0x2760962Au32; // Transforms to "my_sensor_name"
98    }
99}