Skip to main content

codetics_evse_id/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)
3
4//! E-mobility identifiers (IDRR / eMI³ / ISO 15118 format) without the
5//! `COUNTRY*OPERATOR*` prefix.
6//!
7//! Format (IDRR "E-mobility ID-codes", November 2023, Figure 1):
8//!
9//! ```text
10//! <Type> <ID>
11//!   Type: 'E' (EVSE), 'P' (Pool / location) or 'S' (Station)
12//!   ID:   up to 30 alphanumeric characters [A-Za-z0-9]
13//! ```
14//!
15//! Identifiers generated here end with a check character computed with the
16//! IDRR Annex 1 algorithm (Chen, Niemenmaa & Vinck, 2013) over the type
17//! character and the body. Annex 1 defines this check digit for Contract IDs;
18//! the EVSE ID format itself has none. Applying it to the local part keeps
19//! typing errors detectable and stays within the 30-character limit (body ≤ 29).
20
21use std::fmt;
22use std::str::FromStr;
23
24/// Maximum length of the ID part after the type character.
25pub const MAX_ID_LEN: usize = 30;
26/// Maximum body length when a check character is appended.
27pub const MAX_BODY_LEN: usize = MAX_ID_LEN - 1;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
30pub enum IdType {
31    /// Electric Vehicle Supply Equipment.
32    Evse,
33    /// Pool (a location grouping stations).
34    Pool,
35    /// Charging station.
36    Station,
37}
38
39impl IdType {
40    pub const fn as_char(self) -> char {
41        match self {
42            IdType::Evse => 'E',
43            IdType::Pool => 'P',
44            IdType::Station => 'S',
45        }
46    }
47
48    pub fn from_char(c: char) -> Option<IdType> {
49        match c.to_ascii_uppercase() {
50            'E' => Some(IdType::Evse),
51            'P' => Some(IdType::Pool),
52            'S' => Some(IdType::Station),
53            _ => None,
54        }
55    }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum Error {
60    Empty,
61    InvalidType(char),
62    InvalidCharacter(char),
63    TooLong { len: usize, max: usize },
64    CheckDigitMismatch { expected: char, found: char },
65}
66
67impl fmt::Display for Error {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        match self {
70            Error::Empty => f.write_str("identifier is empty"),
71            Error::InvalidType(c) => write!(f, "invalid type character '{c}', expected E, P or S"),
72            Error::InvalidCharacter(c) => {
73                write!(f, "invalid character '{c}', only [A-Za-z0-9] allowed")
74            }
75            Error::TooLong { len, max } => {
76                write!(f, "identifier part has {len} characters, maximum is {max}")
77            }
78            Error::CheckDigitMismatch { expected, found } => write!(
79                f,
80                "check character mismatch: expected '{expected}', found '{found}'"
81            ),
82        }
83    }
84}
85
86impl std::error::Error for Error {}
87
88/// An e-mobility identifier: type character + body + check character.
89#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
90pub struct EvseId {
91    /// Canonical uppercase representation, e.g. `S00002P7`.
92    value: String,
93}
94
95impl EvseId {
96    /// Builds an identifier from a type and a body, appending the check character.
97    /// The body is uppercased.
98    pub fn generate(kind: IdType, body: &str) -> Result<EvseId, Error> {
99        if body.is_empty() {
100            return Err(Error::Empty);
101        }
102        if let Some(c) = body.chars().find(|c| !c.is_ascii_alphanumeric()) {
103            return Err(Error::InvalidCharacter(c));
104        }
105        if body.len() > MAX_BODY_LEN {
106            return Err(Error::TooLong {
107                len: body.len(),
108                max: MAX_BODY_LEN,
109            });
110        }
111        let mut value = String::with_capacity(body.len() + 2);
112        value.push(kind.as_char());
113        value.push_str(&body.to_ascii_uppercase());
114        let check = check_digit(&value)?;
115        value.push(check);
116        Ok(EvseId { value })
117    }
118
119    /// Station identifier for a numeric sequence, e.g. `station(1)` → `S000001` + check.
120    pub fn station(seq: u64) -> EvseId {
121        EvseId::generate(IdType::Station, &base36(seq, 6)).expect("valid body")
122    }
123
124    /// Pool identifier for a numeric sequence.
125    pub fn pool(seq: u64) -> EvseId {
126        EvseId::generate(IdType::Pool, &base36(seq, 6)).expect("valid body")
127    }
128
129    /// EVSE identifier derived from its station identifier and EVSE number,
130    /// e.g. station `S000001X`, evse 2 → `E00000102` + check.
131    pub fn evse_of(station: &EvseId, evse_number: u32) -> Result<EvseId, Error> {
132        let station_body = station.body();
133        EvseId::generate(IdType::Evse, &format!("{station_body}{evse_number:02}"))
134    }
135
136    /// Parses and verifies an identifier (`<Type><body><check>`, case-insensitive).
137    pub fn parse(s: &str) -> Result<EvseId, Error> {
138        let s = s.trim();
139        let mut chars = s.chars();
140        let t = chars.next().ok_or(Error::Empty)?;
141        let kind = IdType::from_char(t).ok_or(Error::InvalidType(t))?;
142        let rest = chars.as_str();
143        if rest.len() < 2 {
144            return Err(Error::Empty);
145        }
146        if let Some(c) = rest.chars().find(|c| !c.is_ascii_alphanumeric()) {
147            return Err(Error::InvalidCharacter(c));
148        }
149        if rest.len() > MAX_ID_LEN {
150            return Err(Error::TooLong {
151                len: rest.len(),
152                max: MAX_ID_LEN,
153            });
154        }
155        let upper = format!("{}{}", kind.as_char(), rest.to_ascii_uppercase());
156        let (payload, found) = upper.split_at(upper.len() - 1);
157        let expected = check_digit(payload)?;
158        let found = found.chars().next().expect("non-empty");
159        if expected != found {
160            return Err(Error::CheckDigitMismatch { expected, found });
161        }
162        Ok(EvseId { value: upper })
163    }
164
165    pub fn kind(&self) -> IdType {
166        IdType::from_char(self.value.as_bytes()[0] as char).expect("validated")
167    }
168
169    /// Body between the type character and the check character.
170    pub fn body(&self) -> &str {
171        &self.value[1..self.value.len() - 1]
172    }
173
174    pub fn check_char(&self) -> char {
175        self.value.as_bytes()[self.value.len() - 1] as char
176    }
177
178    pub fn as_str(&self) -> &str {
179        &self.value
180    }
181
182    /// Full eMI³ form with country and operator prefix, e.g. `FR*ABC*E00000102K`.
183    /// The check character only covers the local part.
184    pub fn with_operator(&self, country: &str, operator: &str) -> String {
185        format!(
186            "{}*{}*{}",
187            country.to_ascii_uppercase(),
188            operator.to_ascii_uppercase(),
189            self.value
190        )
191    }
192}
193
194impl fmt::Display for EvseId {
195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196        f.write_str(&self.value)
197    }
198}
199
200impl FromStr for EvseId {
201    type Err = Error;
202    fn from_str(s: &str) -> Result<Self, Self::Err> {
203        EvseId::parse(s)
204    }
205}
206
207impl AsRef<str> for EvseId {
208    fn as_ref(&self) -> &str {
209        &self.value
210    }
211}
212
213fn base36(mut n: u64, width: usize) -> String {
214    const DIGITS: &[u8; 36] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
215    let mut buf = Vec::new();
216    loop {
217        buf.push(DIGITS[(n % 36) as usize]);
218        n /= 36;
219        if n == 0 {
220            break;
221        }
222    }
223    while buf.len() < width {
224        buf.push(b'0');
225    }
226    buf.reverse();
227    String::from_utf8(buf).expect("ascii")
228}
229
230// ---------------------------------------------------------------------------
231// Check digit: IDRR Annex 1 / Chen et al., over (Z2×Z2) × (Z3×Z3).
232// ---------------------------------------------------------------------------
233
234type M2 = [[u8; 2]; 2];
235type V2 = [u8; 2];
236
237/// P1 over Z2 (characteristic polynomial x² + x + 1, order 3).
238const P1: M2 = [[0, 1], [1, 1]];
239/// P2 over Z3 (characteristic polynomial x² + x + 2, order 8).
240const P2: M2 = [[0, 1], [1, 2]];
241
242fn mat_mul(a: &M2, b: &M2, m: u8) -> M2 {
243    let mut r = [[0u8; 2]; 2];
244    for (i, row) in r.iter_mut().enumerate() {
245        for (j, cell) in row.iter_mut().enumerate() {
246            *cell = ((a[i][0] as u16 * b[0][j] as u16 + a[i][1] as u16 * b[1][j] as u16) % m as u16)
247                as u8;
248        }
249    }
250    r
251}
252
253fn mat_pow(p: &M2, e: u32, m: u8) -> M2 {
254    let mut r = [[1, 0], [0, 1]];
255    for _ in 0..e {
256        r = mat_mul(&r, p, m);
257    }
258    r
259}
260
261/// Row vector × matrix.
262fn vec_mul(v: &V2, p: &M2, m: u8) -> V2 {
263    [
264        ((v[0] as u16 * p[0][0] as u16 + v[1] as u16 * p[1][0] as u16) % m as u16) as u8,
265        ((v[0] as u16 * p[0][1] as u16 + v[1] as u16 * p[1][1] as u16) % m as u16) as u8,
266    ]
267}
268
269fn char_value(c: char) -> Option<u8> {
270    match c.to_ascii_uppercase() {
271        d @ '0'..='9' => Some(d as u8 - b'0'),
272        l @ 'A'..='Z' => Some(l as u8 - b'A' + 10),
273        _ => None,
274    }
275}
276
277/// Computes the check character for an alphanumeric string (case-insensitive).
278pub fn check_digit(input: &str) -> Result<char, Error> {
279    if input.is_empty() {
280        return Err(Error::Empty);
281    }
282    let mut t1: V2 = [0, 0];
283    let mut t2: V2 = [0, 0];
284    let mut n = 0u32;
285    for c in input.chars() {
286        let a = char_value(c).ok_or(Error::InvalidCharacter(c))?;
287        n += 1;
288        // a = q·9 + r, q ∈ Z2×Z2 as (q1, q2) with q = 2·q1 + q2, r ∈ Z3×Z3 as (r1, r2) with r = 3·r1 + r2.
289        let (q, r) = (a / 9, a % 9);
290        let qv: V2 = [q / 2, q % 2];
291        let rv: V2 = [r / 3, r % 3];
292        // P1 has order 3 and P2 order 8, so exponents reduce modulo those.
293        let a1 = vec_mul(&qv, &mat_pow(&P1, n % 3, 2), 2);
294        let a2 = vec_mul(&rv, &mat_pow(&P2, n % 8, 3), 3);
295        t1 = [(t1[0] + a1[0]) % 2, (t1[1] + a1[1]) % 2];
296        t2 = [(t2[0] + a2[0]) % 3, (t2[1] + a2[1]) % 3];
297    }
298    // Solve t + x·P^(n+1) = 0  ⇒  x = −t · P^−(n+1).
299    let k = n + 1;
300    let inv1 = mat_pow(&P1, (3 - k % 3) % 3, 2);
301    let inv2 = mat_pow(&P2, (8 - k % 8) % 8, 3);
302    let q = vec_mul(&t1, &inv1, 2); // −x = x over Z2
303    let r_pos = vec_mul(&t2, &inv2, 3);
304    let r = [(3 - r_pos[0]) % 3, (3 - r_pos[1]) % 3];
305    let value = (q[0] * 2 + q[1]) * 9 + (r[0] * 3 + r[1]);
306    Ok(if value < 10 {
307        (b'0' + value) as char
308    } else {
309        (b'A' + value - 10) as char
310    })
311}
312
313/// Verifies a string whose last character is its check character.
314pub fn verify(input_with_check: &str) -> bool {
315    let Some((idx, last)) = input_with_check.char_indices().last() else {
316        return false;
317    };
318    idx > 0 && check_digit(&input_with_check[..idx]).is_ok_and(|c| c == last.to_ascii_uppercase())
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn annex1_worked_example() {
327        // IDRR Annex 1 example: Contract-ID DE83DUIEN83QGZ has check digit D.
328        assert_eq!(check_digit("DE83DUIEN83QGZ").unwrap(), 'D');
329        assert!(verify("DE83DUIEN83QGZD"));
330        assert!(verify("de83duien83qgzd"));
331        assert!(!verify("DE83DUIEN83QGZE"));
332    }
333
334    #[test]
335    fn matrix_orders() {
336        assert_eq!(mat_pow(&P1, 3, 2), [[1, 0], [0, 1]]);
337        assert_eq!(mat_pow(&P2, 8, 3), [[1, 0], [0, 1]]);
338        // Values listed in Annex 1.
339        assert_eq!(mat_pow(&P1, 2, 2), [[1, 1], [1, 0]]);
340        assert_eq!(mat_pow(&P2, 2, 3), [[1, 2], [2, 2]]);
341        assert_eq!(mat_pow(&P2, 5, 3), [[0, 2], [2, 1]]);
342    }
343
344    #[test]
345    fn detects_common_typing_errors() {
346        let base = "E1234ABCD56";
347        let good = format!("{base}{}", check_digit(base).unwrap());
348        assert!(verify(&good));
349        let bytes = good.as_bytes();
350        // Single substitutions and adjacent transpositions are always detected.
351        for i in 1..bytes.len() - 1 {
352            for c in "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ".bytes() {
353                if c == bytes[i] {
354                    continue;
355                }
356                let mut b = bytes.to_vec();
357                b[i] = c;
358                assert!(
359                    !verify(std::str::from_utf8(&b).unwrap()),
360                    "substitution at {i}"
361                );
362            }
363            if bytes[i] != bytes[i + 1] {
364                let mut b = bytes.to_vec();
365                b.swap(i, i + 1);
366                assert!(
367                    !verify(std::str::from_utf8(&b).unwrap()),
368                    "transposition at {i}"
369                );
370            }
371        }
372    }
373
374    #[test]
375    fn generate_and_parse() {
376        let s = EvseId::station(1);
377        assert_eq!(s.kind(), IdType::Station);
378        assert_eq!(s.body(), "000001");
379        assert_eq!(EvseId::parse(s.as_str()).unwrap(), s);
380        assert_eq!(EvseId::parse(&s.as_str().to_ascii_lowercase()).unwrap(), s);
381
382        let e = EvseId::evse_of(&s, 2).unwrap();
383        assert_eq!(e.kind(), IdType::Evse);
384        assert_eq!(e.body(), "00000102");
385        assert!(verify(e.as_str()));
386        assert!(e.with_operator("fr", "abc").starts_with("FR*ABC*E00000102"));
387
388        assert_eq!(
389            EvseId::generate(IdType::Pool, "x-1"),
390            Err(Error::InvalidCharacter('-'))
391        );
392        assert!(matches!(
393            EvseId::generate(IdType::Pool, &"A".repeat(30)),
394            Err(Error::TooLong { .. })
395        ));
396        assert!(EvseId::generate(IdType::Pool, &"A".repeat(29)).is_ok());
397        assert!(matches!(
398            EvseId::parse("X123"),
399            Err(Error::InvalidType('X'))
400        ));
401        let mut bad = s.as_str().to_owned();
402        bad.replace_range(2..3, "9");
403        assert!(matches!(
404            EvseId::parse(&bad),
405            Err(Error::CheckDigitMismatch { .. })
406        ));
407        assert_eq!(EvseId::station(36 * 36).body(), "000100");
408    }
409}
410
411/// Compiles and runs the examples of the README.
412#[cfg(doctest)]
413#[doc = include_str!("../README.md")]
414pub struct ReadmeDoctests;