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
326fn 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!(×tamp[4..5], "-");
367 assert_eq!(×tamp[7..8], "-");
368 assert_eq!(×tamp[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 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}