Skip to main content

rust_ethernet_ip/
schema.rs

1use crate::tag_manager::{
2    TagMetadata, TagPermissions as MetadataPermissions, TagScope as MetadataScope,
3};
4use crate::udt::{TagAttributes, TagPermissions, TagScope, UdtDefinition, UdtMember};
5use crate::{RouteHop, RoutePath};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct SchemaExport {
10    pub schema_version: String,
11    pub generated_at_utc: String,
12    pub library: SchemaLibraryInfo,
13    pub target: SchemaTargetInfo,
14    pub capabilities: SchemaCapabilities,
15    pub tags: Vec<SchemaTag>,
16    pub udts: Vec<SchemaUdt>,
17    pub warnings: Vec<String>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SchemaLibraryInfo {
22    pub name: String,
23    pub version: String,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SchemaTargetInfo {
28    pub address: Option<String>,
29    pub route_path: Option<SchemaRoutePath>,
30    pub controller_family: Option<String>,
31    pub firmware_revision: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct SchemaRoutePath {
36    pub slots: Vec<u8>,
37    pub ports: Vec<u8>,
38    pub addresses: Vec<String>,
39    pub hops: Vec<SchemaRouteHop>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(tag = "kind", rename_all = "snake_case")]
44pub enum SchemaRouteHop {
45    Backplane { port: u8, slot: u8 },
46    Ethernet { port: u8, address: String },
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SchemaCapabilities {
51    pub tag_discovery: bool,
52    pub tag_attributes: bool,
53    pub udt_definitions: bool,
54    pub program_tags: bool,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct SchemaTag {
59    pub name: String,
60    pub scope: SchemaScope,
61    pub data_type: SchemaDataType,
62    pub dimensions: Vec<u32>,
63    pub size_bytes: u32,
64    pub permissions: String,
65    pub template_instance_id: Option<u32>,
66    pub udt_name: Option<String>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct SchemaUdt {
71    pub name: String,
72    pub template_instance_id: Option<u32>,
73    pub size_bytes: u32,
74    pub members: Vec<SchemaUdtMember>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct SchemaUdtMember {
79    pub name: String,
80    pub offset_bytes: u32,
81    pub size_bytes: u32,
82    pub data_type: SchemaDataType,
83    pub dimensions: Vec<u32>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct SchemaScope {
88    pub kind: String,
89    pub program: Option<String>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct SchemaDataType {
94    pub cip_code: u16,
95    pub name: String,
96    pub kind: String,
97}
98
99impl SchemaExport {
100    pub fn new(route_path: Option<&RoutePath>) -> Self {
101        let warnings = vec![
102            "Target address is not currently retained on EipClient and is omitted from schema export."
103                .to_string(),
104        ];
105
106        Self {
107            schema_version: "0.1".to_string(),
108            generated_at_utc: current_utc_timestamp_rfc3339(),
109            library: SchemaLibraryInfo {
110                name: env!("CARGO_PKG_NAME").to_string(),
111                version: env!("CARGO_PKG_VERSION").to_string(),
112            },
113            target: SchemaTargetInfo {
114                address: None,
115                route_path: route_path.map(Into::into),
116                controller_family: None,
117                firmware_revision: None,
118            },
119            capabilities: SchemaCapabilities {
120                tag_discovery: true,
121                tag_attributes: true,
122                udt_definitions: true,
123                program_tags: false,
124            },
125            tags: Vec::new(),
126            udts: Vec::new(),
127            warnings,
128        }
129    }
130}
131
132impl From<&RoutePath> for SchemaRoutePath {
133    fn from(value: &RoutePath) -> Self {
134        Self {
135            slots: value.slots(),
136            ports: value.ports(),
137            addresses: value.addresses(),
138            hops: value.hops().iter().map(Into::into).collect(),
139        }
140    }
141}
142
143impl From<&RouteHop> for SchemaRouteHop {
144    fn from(value: &RouteHop) -> Self {
145        match value {
146            RouteHop::Backplane { port, slot } => Self::Backplane {
147                port: *port,
148                slot: *slot,
149            },
150            RouteHop::Ethernet { port, address } => Self::Ethernet {
151                port: *port,
152                address: address.clone(),
153            },
154        }
155    }
156}
157
158impl From<&TagAttributes> for SchemaTag {
159    fn from(value: &TagAttributes) -> Self {
160        Self {
161            name: value.name.clone(),
162            scope: schema_scope_from_tag_attributes(&value.scope),
163            data_type: SchemaDataType::from_cip(value.data_type, &value.data_type_name),
164            dimensions: value.dimensions.clone(),
165            size_bytes: value.size,
166            permissions: schema_permissions_from_tag_attributes(&value.permissions),
167            template_instance_id: value.template_instance_id,
168            udt_name: (value.data_type == 0x00A0).then(|| value.name.clone()),
169        }
170    }
171}
172
173impl From<&TagMetadata> for SchemaTag {
174    fn from(value: &TagMetadata) -> Self {
175        Self {
176            name: String::new(),
177            scope: schema_scope_from_metadata(&value.scope),
178            data_type: SchemaDataType::from_cip(value.data_type, data_type_name(value.data_type)),
179            dimensions: value.dimensions.clone(),
180            size_bytes: value.size,
181            permissions: schema_permissions_from_metadata(&value.permissions),
182            template_instance_id: None,
183            udt_name: value.is_structure().then(|| "structure".to_string()),
184        }
185    }
186}
187
188impl SchemaUdt {
189    pub fn from_definition(
190        definition: &UdtDefinition,
191        template_instance_id: Option<u32>,
192        source_tag_size: u32,
193    ) -> Self {
194        Self {
195            name: definition.name.clone(),
196            template_instance_id,
197            size_bytes: source_tag_size,
198            members: definition
199                .members
200                .iter()
201                .map(SchemaUdtMember::from)
202                .collect(),
203        }
204    }
205}
206
207impl From<&UdtMember> for SchemaUdtMember {
208    fn from(value: &UdtMember) -> Self {
209        Self {
210            name: value.name.clone(),
211            offset_bytes: value.offset,
212            size_bytes: value.size,
213            data_type: SchemaDataType::from_cip(value.data_type, data_type_name(value.data_type)),
214            dimensions: Vec::new(),
215        }
216    }
217}
218
219impl SchemaDataType {
220    pub fn from_cip(cip_code: u16, name: &str) -> Self {
221        Self {
222            cip_code,
223            name: name.to_string(),
224            kind: data_type_kind(cip_code).to_string(),
225        }
226    }
227}
228
229fn schema_scope_from_tag_attributes(scope: &TagScope) -> SchemaScope {
230    match scope {
231        TagScope::Controller => SchemaScope {
232            kind: "controller".to_string(),
233            program: None,
234        },
235        TagScope::Program(name) => SchemaScope {
236            kind: "program".to_string(),
237            program: Some(name.clone()),
238        },
239        TagScope::Unknown => SchemaScope {
240            kind: "unknown".to_string(),
241            program: None,
242        },
243    }
244}
245
246fn schema_scope_from_metadata(scope: &MetadataScope) -> SchemaScope {
247    match scope {
248        MetadataScope::Controller => SchemaScope {
249            kind: "controller".to_string(),
250            program: None,
251        },
252        MetadataScope::Program(name) => SchemaScope {
253            kind: "program".to_string(),
254            program: Some(name.clone()),
255        },
256        MetadataScope::Global => SchemaScope {
257            kind: "global".to_string(),
258            program: None,
259        },
260        MetadataScope::Local => SchemaScope {
261            kind: "local".to_string(),
262            program: None,
263        },
264    }
265}
266
267fn schema_permissions_from_tag_attributes(permissions: &TagPermissions) -> String {
268    match permissions {
269        TagPermissions::ReadOnly => "read_only".to_string(),
270        TagPermissions::ReadWrite => "read_write".to_string(),
271        TagPermissions::WriteOnly => "write_only".to_string(),
272        TagPermissions::Unknown => "unknown".to_string(),
273    }
274}
275
276fn schema_permissions_from_metadata(permissions: &MetadataPermissions) -> String {
277    match (permissions.readable, permissions.writable) {
278        (true, true) => "read_write",
279        (true, false) => "read_only",
280        (false, true) => "write_only",
281        (false, false) => "unknown",
282    }
283    .to_string()
284}
285
286fn data_type_kind(cip_code: u16) -> &'static str {
287    match cip_code {
288        0x00A0 | 0x02A0 => "udt",
289        0x00CE | 0x00DA => "string",
290        0x00C1..=0x00CB | 0x00D3 => "primitive",
291        _ => "unknown",
292    }
293}
294
295fn data_type_name(cip_code: u16) -> &'static str {
296    match cip_code {
297        0x00A0 => "UDT",
298        0x02A0 => "STRUCTURE",
299        0x00C1 => "BOOL",
300        0x00C2 => "SINT",
301        0x00C3 => "INT",
302        0x00C4 => "DINT",
303        0x00C5 => "LINT",
304        0x00C6 => "USINT",
305        0x00C7 => "UINT",
306        0x00C8 => "UDINT",
307        0x00C9 => "ULINT",
308        0x00CA => "REAL",
309        0x00CB => "LREAL",
310        0x00CE => "STRING",
311        0x00DA => "STRING",
312        0x00D3 => "UDINT",
313        _ => "UNKNOWN",
314    }
315}
316
317fn current_utc_timestamp_rfc3339() -> String {
318    use std::time::{SystemTime, UNIX_EPOCH};
319    let secs = SystemTime::now()
320        .duration_since(UNIX_EPOCH)
321        .map(|d| d.as_secs() as i64)
322        .unwrap_or(0);
323    format_unix_seconds_as_rfc3339(secs)
324}
325
326// Howard Hinnant's civil-from-days algorithm; valid for any i64 Unix second.
327// Avoids platform libc divergence (gmtime_r/gmtime_s/strftime).
328fn format_unix_seconds_as_rfc3339(secs: i64) -> String {
329    let days = secs.div_euclid(86_400);
330    let tod = secs.rem_euclid(86_400);
331    let hour = (tod / 3600) as u32;
332    let minute = ((tod % 3600) / 60) as u32;
333    let second = (tod % 60) as u32;
334
335    let z = days + 719_468;
336    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
337    let doe = (z - era * 146_097) as u64;
338    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
339    let y = yoe as i64 + era * 400;
340    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
341    let mp = (5 * doy + 2) / 153;
342    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
343    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
344    let year = if m <= 2 { y + 1 } else { y };
345
346    format!("{year:04}-{m:02}-{d:02}T{hour:02}:{minute:02}:{second:02}Z")
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::udt::{TagAttributes, TagPermissions, TagScope, UdtDefinition, UdtMember};
353
354    #[test]
355    fn schema_data_type_classifies_core_types() {
356        assert_eq!(SchemaDataType::from_cip(0x00C4, "DINT").kind, "primitive");
357        assert_eq!(SchemaDataType::from_cip(0x00CE, "STRING").kind, "string");
358        assert_eq!(SchemaDataType::from_cip(0x00A0, "UDT").kind, "udt");
359    }
360
361    #[test]
362    fn timestamp_helper_returns_rfc3339_utc_shape() {
363        let timestamp = current_utc_timestamp_rfc3339();
364        assert_eq!(timestamp.len(), 20);
365        assert!(timestamp.ends_with('Z'));
366        assert_eq!(&timestamp[4..5], "-");
367        assert_eq!(&timestamp[7..8], "-");
368        assert_eq!(&timestamp[10..11], "T");
369    }
370
371    #[test]
372    fn rfc3339_format_matches_known_unix_seconds() {
373        assert_eq!(format_unix_seconds_as_rfc3339(0), "1970-01-01T00:00:00Z");
374        assert_eq!(
375            format_unix_seconds_as_rfc3339(1_700_000_000),
376            "2023-11-14T22:13:20Z"
377        );
378        // 2024-02-29T12:34:56Z — leap year boundary.
379        assert_eq!(
380            format_unix_seconds_as_rfc3339(1_709_210_096),
381            "2024-02-29T12:34:56Z"
382        );
383    }
384
385    #[test]
386    fn schema_tag_maps_program_scope_and_template_id() {
387        let attrs = TagAttributes {
388            name: "Program:Main.MotorData".to_string(),
389            data_type: 0x00A0,
390            data_type_name: "UDT".to_string(),
391            dimensions: vec![4],
392            permissions: TagPermissions::ReadWrite,
393            scope: TagScope::Program("Main".to_string()),
394            template_instance_id: Some(123),
395            size: 64,
396        };
397
398        let tag = SchemaTag::from(&attrs);
399        assert_eq!(tag.name, "Program:Main.MotorData");
400        assert_eq!(tag.scope.kind, "program");
401        assert_eq!(tag.scope.program.as_deref(), Some("Main"));
402        assert_eq!(tag.data_type.kind, "udt");
403        assert_eq!(tag.template_instance_id, Some(123));
404        assert_eq!(tag.dimensions, vec![4]);
405        assert_eq!(tag.permissions, "read_write");
406        assert_eq!(tag.udt_name.as_deref(), Some("Program:Main.MotorData"));
407    }
408
409    #[test]
410    fn schema_udt_maps_members_and_size() {
411        let definition = UdtDefinition {
412            name: "MotorData".to_string(),
413            members: vec![
414                UdtMember {
415                    name: "Speed".to_string(),
416                    data_type: 0x00CA,
417                    offset: 0,
418                    size: 4,
419                },
420                UdtMember {
421                    name: "Enabled".to_string(),
422                    data_type: 0x00C1,
423                    offset: 4,
424                    size: 1,
425                },
426            ],
427        };
428
429        let udt = SchemaUdt::from_definition(&definition, Some(77), 64);
430        assert_eq!(udt.name, "MotorData");
431        assert_eq!(udt.template_instance_id, Some(77));
432        assert_eq!(udt.size_bytes, 64);
433        assert_eq!(udt.members.len(), 2);
434        assert_eq!(udt.members[0].name, "Speed");
435        assert_eq!(udt.members[0].data_type.name, "REAL");
436        assert_eq!(udt.members[1].name, "Enabled");
437        assert_eq!(udt.members[1].data_type.name, "BOOL");
438    }
439
440    #[test]
441    fn schema_export_serializes_stable_top_level_fields() {
442        let mut export = SchemaExport::new(None);
443        export.tags.push(SchemaTag {
444            name: "ProductionCount".to_string(),
445            scope: SchemaScope {
446                kind: "controller".to_string(),
447                program: None,
448            },
449            data_type: SchemaDataType::from_cip(0x00C4, "DINT"),
450            dimensions: Vec::new(),
451            size_bytes: 4,
452            permissions: "read_write".to_string(),
453            template_instance_id: None,
454            udt_name: None,
455        });
456
457        let json = serde_json::to_value(&export).expect("serialize schema export");
458        assert_eq!(json["schema_version"], "0.1");
459        assert_eq!(json["library"]["name"], env!("CARGO_PKG_NAME"));
460        assert!(json["generated_at_utc"].as_str().is_some());
461        assert!(json["tags"].is_array());
462        assert!(json["warnings"].is_array());
463    }
464}