1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SchemaExport {
13 pub schema_version: String,
15 pub generated_at_utc: String,
17 pub library: SchemaLibraryInfo,
19 pub target: SchemaTargetInfo,
21 pub capabilities: SchemaCapabilities,
23 pub tags: Vec<SchemaTag>,
25 pub udts: Vec<SchemaUdt>,
27 pub warnings: Vec<String>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct SchemaLibraryInfo {
34 pub name: String,
36 pub version: String,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct SchemaTargetInfo {
43 pub address: Option<String>,
45 pub route_path: Option<SchemaRoutePath>,
47 pub controller_family: Option<String>,
49 pub firmware_revision: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct SchemaRoutePath {
56 pub slots: Vec<u8>,
58 pub ports: Vec<u8>,
60 pub addresses: Vec<String>,
62 pub hops: Vec<SchemaRouteHop>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(tag = "kind", rename_all = "snake_case")]
69pub enum SchemaRouteHop {
70 Backplane {
72 port: u8,
74 slot: u8,
76 },
77 Ethernet {
79 port: u8,
81 address: String,
83 },
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SchemaCapabilities {
89 pub tag_discovery: bool,
91 pub tag_attributes: bool,
93 pub udt_definitions: bool,
95 pub program_tags: bool,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct SchemaTag {
102 pub name: String,
104 pub scope: SchemaScope,
106 pub data_type: SchemaDataType,
108 pub dimensions: Vec<u32>,
110 pub size_bytes: u32,
112 pub permissions: String,
114 pub template_instance_id: Option<u32>,
116 pub udt_name: Option<String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct SchemaUdt {
123 pub name: String,
125 pub template_instance_id: Option<u32>,
127 pub size_bytes: u32,
129 pub members: Vec<SchemaUdtMember>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct SchemaUdtMember {
136 pub name: String,
138 pub offset_bytes: u32,
140 pub size_bytes: u32,
142 pub data_type: SchemaDataType,
144 pub dimensions: Vec<u32>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct SchemaScope {
151 pub kind: String,
153 pub program: Option<String>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct SchemaDataType {
160 pub cip_code: u16,
162 pub name: String,
164 pub kind: String,
166}
167
168impl SchemaExport {
169 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 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 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
398fn 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!(×tamp[4..5], "-");
439 assert_eq!(×tamp[7..8], "-");
440 assert_eq!(×tamp[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 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}