Skip to main content

rust_ethernet_ip/
schema.rs

1//! Serializable, language-neutral controller schema export models.
2
3use crate::tag_manager::{
4    TagMetadata, TagPermissions as MetadataPermissions, TagScope as MetadataScope,
5};
6use crate::udt::{TagAttributes, TagPermissions, TagScope, UdtDefinition, UdtMember};
7use crate::{RouteHop, RoutePath};
8use serde::{Deserialize, Serialize};
9
10/// Complete schema document exported from a connected client.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SchemaExport {
13    /// Version of the JSON schema contract, independent of the library version.
14    pub schema_version: String,
15    /// UTC generation time in RFC 3339 format.
16    pub generated_at_utc: String,
17    /// Library identity that produced the export.
18    pub library: SchemaLibraryInfo,
19    /// Controller address, route, and identity information when known.
20    pub target: SchemaTargetInfo,
21    /// Discovery surfaces available in this export.
22    pub capabilities: SchemaCapabilities,
23    /// Discovered controller- and program-scoped tags.
24    pub tags: Vec<SchemaTag>,
25    /// Discovered user-defined types.
26    pub udts: Vec<SchemaUdt>,
27    /// Omissions or uncertainty consumers should surface.
28    pub warnings: Vec<String>,
29}
30
31/// Name and version of the exporting library.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct SchemaLibraryInfo {
34    /// Cargo package name.
35    pub name: String,
36    /// Semantic library version.
37    pub version: String,
38}
39
40/// Best-effort identity of the schema source controller.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct SchemaTargetInfo {
43    /// Socket address, when retained by the client.
44    pub address: Option<String>,
45    /// Ordered route to the controller, when configured.
46    pub route_path: Option<SchemaRoutePath>,
47    /// Controller product family, when discovered.
48    pub controller_family: Option<String>,
49    /// Controller firmware revision, when discovered.
50    pub firmware_revision: Option<String>,
51}
52
53/// Serializable view of an ordered CIP route.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct SchemaRoutePath {
56    /// Backplane slots, retained for compatibility with older consumers.
57    pub slots: Vec<u8>,
58    /// Route ports, retained for compatibility with older consumers.
59    pub ports: Vec<u8>,
60    /// Network addresses, retained for compatibility with older consumers.
61    pub addresses: Vec<String>,
62    /// Authoritative ordered route hops.
63    pub hops: Vec<SchemaRouteHop>,
64}
65
66/// One ordered hop in an exported CIP route.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(tag = "kind", rename_all = "snake_case")]
69pub enum SchemaRouteHop {
70    /// Backplane port followed by a chassis slot.
71    Backplane {
72        /// CIP port number.
73        port: u8,
74        /// Target chassis slot.
75        slot: u8,
76    },
77    /// Network port followed by a link address.
78    Ethernet {
79        /// CIP port number.
80        port: u8,
81        /// Link address, normally an IP address.
82        address: String,
83    },
84}
85
86/// Indicates which discovery features contributed to an export.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SchemaCapabilities {
89    /// Controller tag discovery was available.
90    pub tag_discovery: bool,
91    /// Detailed tag attributes were available.
92    pub tag_attributes: bool,
93    /// UDT template definitions were available.
94    pub udt_definitions: bool,
95    /// Program-scoped tag enumeration was available.
96    pub program_tags: bool,
97}
98
99/// Portable description of one Logix tag.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct SchemaTag {
102    /// Fully qualified symbolic tag name.
103    pub name: String,
104    /// Controller or program scope.
105    pub scope: SchemaScope,
106    /// CIP data type description.
107    pub data_type: SchemaDataType,
108    /// Array dimensions, empty for a scalar.
109    pub dimensions: Vec<u32>,
110    /// Encoded value size in bytes.
111    pub size_bytes: u32,
112    /// Stable permission label such as `read_write`.
113    pub permissions: String,
114    /// UDT template instance id, when known.
115    pub template_instance_id: Option<u32>,
116    /// UDT name, when known.
117    pub udt_name: Option<String>,
118}
119
120/// Portable description of a Logix user-defined type.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct SchemaUdt {
123    /// Logix data type name.
124    pub name: String,
125    /// Controller template instance id, when known.
126    pub template_instance_id: Option<u32>,
127    /// Encoded structure size in bytes.
128    pub size_bytes: u32,
129    /// Members in template order.
130    pub members: Vec<SchemaUdtMember>,
131}
132
133/// Portable description of one UDT member.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct SchemaUdtMember {
136    /// Member name.
137    pub name: String,
138    /// Byte offset from the start of the structure.
139    pub offset_bytes: u32,
140    /// Encoded member size in bytes.
141    pub size_bytes: u32,
142    /// Member CIP data type.
143    pub data_type: SchemaDataType,
144    /// Array dimensions, empty for a scalar member.
145    pub dimensions: Vec<u32>,
146}
147
148/// Portable controller/program scope representation.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct SchemaScope {
151    /// Stable scope label: `controller`, `program`, `global`, `local`, or `unknown`.
152    pub kind: String,
153    /// Program name when `kind` is `program`.
154    pub program: Option<String>,
155}
156
157/// Portable CIP data type representation.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct SchemaDataType {
160    /// Numeric CIP type code.
161    pub cip_code: u16,
162    /// Human-readable type name.
163    pub name: String,
164    /// Stable broad kind such as `integer`, `float`, or `structure`.
165    pub kind: String,
166}
167
168impl SchemaExport {
169    /// Creates an empty export populated with library and route metadata.
170    pub fn new(route_path: Option<&RoutePath>) -> Self {
171        let warnings = vec![
172            "Target address is not currently retained on EipClient and is omitted from schema export."
173                .to_string(),
174        ];
175
176        Self {
177            schema_version: "0.1".to_string(),
178            generated_at_utc: current_utc_timestamp_rfc3339(),
179            library: SchemaLibraryInfo {
180                name: env!("CARGO_PKG_NAME").to_string(),
181                version: env!("CARGO_PKG_VERSION").to_string(),
182            },
183            target: SchemaTargetInfo {
184                address: None,
185                route_path: route_path.map(Into::into),
186                controller_family: None,
187                firmware_revision: None,
188            },
189            capabilities: SchemaCapabilities {
190                tag_discovery: true,
191                tag_attributes: true,
192                udt_definitions: true,
193                program_tags: false,
194            },
195            tags: Vec::new(),
196            udts: Vec::new(),
197            warnings,
198        }
199    }
200}
201
202impl From<&RoutePath> for SchemaRoutePath {
203    fn from(value: &RoutePath) -> Self {
204        Self {
205            slots: value.slots(),
206            ports: value.ports(),
207            addresses: value.addresses(),
208            hops: value.hops().iter().map(Into::into).collect(),
209        }
210    }
211}
212
213impl From<&RouteHop> for SchemaRouteHop {
214    fn from(value: &RouteHop) -> Self {
215        match value {
216            RouteHop::Backplane { port, slot } => Self::Backplane {
217                port: *port,
218                slot: *slot,
219            },
220            RouteHop::Ethernet { port, address } => Self::Ethernet {
221                port: *port,
222                address: address.clone(),
223            },
224        }
225    }
226}
227
228impl From<&TagAttributes> for SchemaTag {
229    fn from(value: &TagAttributes) -> Self {
230        Self {
231            name: value.name.clone(),
232            scope: schema_scope_from_tag_attributes(&value.scope),
233            data_type: SchemaDataType::from_cip(value.data_type, &value.data_type_name),
234            dimensions: value.dimensions.clone(),
235            size_bytes: value.size,
236            permissions: schema_permissions_from_tag_attributes(&value.permissions),
237            template_instance_id: value.template_instance_id,
238            udt_name: (value.data_type == 0x00A0).then(|| value.name.clone()),
239        }
240    }
241}
242
243impl From<&TagMetadata> for SchemaTag {
244    fn from(value: &TagMetadata) -> Self {
245        Self {
246            name: String::new(),
247            scope: schema_scope_from_metadata(&value.scope),
248            data_type: SchemaDataType::from_cip(value.data_type, data_type_name(value.data_type)),
249            dimensions: value.dimensions.clone(),
250            size_bytes: value.size,
251            permissions: schema_permissions_from_metadata(&value.permissions),
252            template_instance_id: None,
253            udt_name: value.is_structure().then(|| "structure".to_string()),
254        }
255    }
256}
257
258impl SchemaUdt {
259    /// Converts an internal UDT definition into the portable schema form.
260    pub fn from_definition(
261        definition: &UdtDefinition,
262        template_instance_id: Option<u32>,
263        source_tag_size: u32,
264    ) -> Self {
265        Self {
266            name: definition.name.clone(),
267            template_instance_id,
268            size_bytes: source_tag_size,
269            members: definition
270                .members
271                .iter()
272                .map(SchemaUdtMember::from)
273                .collect(),
274        }
275    }
276}
277
278impl From<&UdtMember> for SchemaUdtMember {
279    fn from(value: &UdtMember) -> Self {
280        Self {
281            name: value.name.clone(),
282            offset_bytes: value.offset,
283            size_bytes: value.size,
284            data_type: SchemaDataType::from_cip(value.data_type, data_type_name(value.data_type)),
285            dimensions: Vec::new(),
286        }
287    }
288}
289
290impl SchemaDataType {
291    /// Creates a portable data type from a CIP code and display name.
292    pub fn from_cip(cip_code: u16, name: &str) -> Self {
293        Self {
294            cip_code,
295            name: name.to_string(),
296            kind: data_type_kind(cip_code).to_string(),
297        }
298    }
299}
300
301fn schema_scope_from_tag_attributes(scope: &TagScope) -> SchemaScope {
302    match scope {
303        TagScope::Controller => SchemaScope {
304            kind: "controller".to_string(),
305            program: None,
306        },
307        TagScope::Program(name) => SchemaScope {
308            kind: "program".to_string(),
309            program: Some(name.clone()),
310        },
311        TagScope::Unknown => SchemaScope {
312            kind: "unknown".to_string(),
313            program: None,
314        },
315    }
316}
317
318fn schema_scope_from_metadata(scope: &MetadataScope) -> SchemaScope {
319    match scope {
320        MetadataScope::Controller => SchemaScope {
321            kind: "controller".to_string(),
322            program: None,
323        },
324        MetadataScope::Program(name) => SchemaScope {
325            kind: "program".to_string(),
326            program: Some(name.clone()),
327        },
328        MetadataScope::Global => SchemaScope {
329            kind: "global".to_string(),
330            program: None,
331        },
332        MetadataScope::Local => SchemaScope {
333            kind: "local".to_string(),
334            program: None,
335        },
336    }
337}
338
339fn schema_permissions_from_tag_attributes(permissions: &TagPermissions) -> String {
340    match permissions {
341        TagPermissions::ReadOnly => "read_only".to_string(),
342        TagPermissions::ReadWrite => "read_write".to_string(),
343        TagPermissions::WriteOnly => "write_only".to_string(),
344        TagPermissions::Unknown => "unknown".to_string(),
345    }
346}
347
348fn schema_permissions_from_metadata(permissions: &MetadataPermissions) -> String {
349    match (permissions.readable, permissions.writable) {
350        (true, true) => "read_write",
351        (true, false) => "read_only",
352        (false, true) => "write_only",
353        (false, false) => "unknown",
354    }
355    .to_string()
356}
357
358fn data_type_kind(cip_code: u16) -> &'static str {
359    match cip_code {
360        0x00A0 | 0x02A0 => "udt",
361        0x00CE | 0x00DA => "string",
362        0x00C1..=0x00CB | 0x00D3 => "primitive",
363        _ => "unknown",
364    }
365}
366
367fn data_type_name(cip_code: u16) -> &'static str {
368    match cip_code {
369        0x00A0 => "UDT",
370        0x02A0 => "STRUCTURE",
371        0x00C1 => "BOOL",
372        0x00C2 => "SINT",
373        0x00C3 => "INT",
374        0x00C4 => "DINT",
375        0x00C5 => "LINT",
376        0x00C6 => "USINT",
377        0x00C7 => "UINT",
378        0x00C8 => "UDINT",
379        0x00C9 => "ULINT",
380        0x00CA => "REAL",
381        0x00CB => "LREAL",
382        0x00CE => "STRING",
383        0x00DA => "STRING",
384        0x00D3 => "UDINT",
385        _ => "UNKNOWN",
386    }
387}
388
389fn current_utc_timestamp_rfc3339() -> String {
390    use std::time::{SystemTime, UNIX_EPOCH};
391    let secs = SystemTime::now()
392        .duration_since(UNIX_EPOCH)
393        .map(|d| d.as_secs() as i64)
394        .unwrap_or(0);
395    format_unix_seconds_as_rfc3339(secs)
396}
397
398// Howard Hinnant's civil-from-days algorithm; valid for any i64 Unix second.
399// Avoids platform libc divergence (gmtime_r/gmtime_s/strftime).
400fn format_unix_seconds_as_rfc3339(secs: i64) -> String {
401    let days = secs.div_euclid(86_400);
402    let tod = secs.rem_euclid(86_400);
403    let hour = (tod / 3600) as u32;
404    let minute = ((tod % 3600) / 60) as u32;
405    let second = (tod % 60) as u32;
406
407    let z = days + 719_468;
408    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
409    let doe = (z - era * 146_097) as u64;
410    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
411    let y = yoe as i64 + era * 400;
412    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
413    let mp = (5 * doy + 2) / 153;
414    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
415    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
416    let year = if m <= 2 { y + 1 } else { y };
417
418    format!("{year:04}-{m:02}-{d:02}T{hour:02}:{minute:02}:{second:02}Z")
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use crate::udt::{TagAttributes, TagPermissions, TagScope, UdtDefinition, UdtMember};
425
426    #[test]
427    fn schema_data_type_classifies_core_types() {
428        assert_eq!(SchemaDataType::from_cip(0x00C4, "DINT").kind, "primitive");
429        assert_eq!(SchemaDataType::from_cip(0x00CE, "STRING").kind, "string");
430        assert_eq!(SchemaDataType::from_cip(0x00A0, "UDT").kind, "udt");
431    }
432
433    #[test]
434    fn timestamp_helper_returns_rfc3339_utc_shape() {
435        let timestamp = current_utc_timestamp_rfc3339();
436        assert_eq!(timestamp.len(), 20);
437        assert!(timestamp.ends_with('Z'));
438        assert_eq!(&timestamp[4..5], "-");
439        assert_eq!(&timestamp[7..8], "-");
440        assert_eq!(&timestamp[10..11], "T");
441    }
442
443    #[test]
444    fn rfc3339_format_matches_known_unix_seconds() {
445        assert_eq!(format_unix_seconds_as_rfc3339(0), "1970-01-01T00:00:00Z");
446        assert_eq!(
447            format_unix_seconds_as_rfc3339(1_700_000_000),
448            "2023-11-14T22:13:20Z"
449        );
450        // 2024-02-29T12:34:56Z — leap year boundary.
451        assert_eq!(
452            format_unix_seconds_as_rfc3339(1_709_210_096),
453            "2024-02-29T12:34:56Z"
454        );
455    }
456
457    #[test]
458    fn schema_tag_maps_program_scope_and_template_id() {
459        let attrs = TagAttributes {
460            name: "Program:Main.MotorData".to_string(),
461            data_type: 0x00A0,
462            data_type_name: "UDT".to_string(),
463            dimensions: vec![4],
464            permissions: TagPermissions::ReadWrite,
465            scope: TagScope::Program("Main".to_string()),
466            template_instance_id: Some(123),
467            size: 64,
468        };
469
470        let tag = SchemaTag::from(&attrs);
471        assert_eq!(tag.name, "Program:Main.MotorData");
472        assert_eq!(tag.scope.kind, "program");
473        assert_eq!(tag.scope.program.as_deref(), Some("Main"));
474        assert_eq!(tag.data_type.kind, "udt");
475        assert_eq!(tag.template_instance_id, Some(123));
476        assert_eq!(tag.dimensions, vec![4]);
477        assert_eq!(tag.permissions, "read_write");
478        assert_eq!(tag.udt_name.as_deref(), Some("Program:Main.MotorData"));
479    }
480
481    #[test]
482    fn schema_udt_maps_members_and_size() {
483        let definition = UdtDefinition {
484            name: "MotorData".to_string(),
485            members: vec![
486                UdtMember {
487                    name: "Speed".to_string(),
488                    data_type: 0x00CA,
489                    offset: 0,
490                    size: 4,
491                },
492                UdtMember {
493                    name: "Enabled".to_string(),
494                    data_type: 0x00C1,
495                    offset: 4,
496                    size: 1,
497                },
498            ],
499        };
500
501        let udt = SchemaUdt::from_definition(&definition, Some(77), 64);
502        assert_eq!(udt.name, "MotorData");
503        assert_eq!(udt.template_instance_id, Some(77));
504        assert_eq!(udt.size_bytes, 64);
505        assert_eq!(udt.members.len(), 2);
506        assert_eq!(udt.members[0].name, "Speed");
507        assert_eq!(udt.members[0].data_type.name, "REAL");
508        assert_eq!(udt.members[1].name, "Enabled");
509        assert_eq!(udt.members[1].data_type.name, "BOOL");
510    }
511
512    #[test]
513    fn schema_export_serializes_stable_top_level_fields() {
514        let mut export = SchemaExport::new(None);
515        export.tags.push(SchemaTag {
516            name: "ProductionCount".to_string(),
517            scope: SchemaScope {
518                kind: "controller".to_string(),
519                program: None,
520            },
521            data_type: SchemaDataType::from_cip(0x00C4, "DINT"),
522            dimensions: Vec::new(),
523            size_bytes: 4,
524            permissions: "read_write".to_string(),
525            template_instance_id: None,
526            udt_name: None,
527        });
528
529        let json = serde_json::to_value(&export).expect("serialize schema export");
530        assert_eq!(json["schema_version"], "0.1");
531        assert_eq!(json["library"]["name"], env!("CARGO_PKG_NAME"));
532        assert!(json["generated_at_utc"].as_str().is_some());
533        assert!(json["tags"].is_array());
534        assert!(json["warnings"].is_array());
535    }
536}