snap7-client 0.1.7

Async Rust client for Siemens S7 PLCs over ISO-on-TCP (S7Comm and S7CommPlus)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
use std::time::Duration;

#[derive(Debug, Clone)]
pub struct ConnectParams {
    pub rack: u8,
    pub slot: u8,
    pub pdu_size: u16,
    pub connect_timeout: Duration,
    pub request_timeout: Duration,
}

impl Default for ConnectParams {
    fn default() -> Self {
        Self {
            rack: 0,
            slot: 1,
            pdu_size: 480,
            connect_timeout: Duration::from_secs(5),
            request_timeout: Duration::from_secs(10),
        }
    }
}

/// PLC run-time status returned by [`S7Client::get_plc_status`](crate::S7Client::get_plc_status).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlcStatus {
    /// Status unknown or not available.
    Unknown = 0x00,
    /// PLC is in STOP mode.
    Stop = 0x04,
    /// PLC is in RUN mode.
    Run = 0x08,
}

/// Result of [`S7Client::get_order_code`](crate::S7Client::get_order_code).
#[derive(Debug, Clone)]
pub struct OrderCode {
    /// The order number (e.g. `"6ES7 317-2EK14-0AB0"`).
    pub code: String,
    /// Firmware version major component.
    pub v1: u8,
    /// Firmware version minor component.
    pub v2: u8,
    /// Firmware version patch component.
    pub v3: u8,
}

/// Protocol variant used by the PLC.
///
/// - **S7** — Classic S7 protocol, used by S7-300, S7-400, S7-1200
/// - **S7Plus** — S7+ (S7-Plus) protocol, used by S7-1500
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Protocol {
    /// Classic S7 protocol (S7-300, S7-400, S7-1200).
    S7,
    /// S7+ protocol (S7-1500).
    S7Plus,
}

impl std::fmt::Display for Protocol {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Protocol::S7 => write!(f, "S7"),
            Protocol::S7Plus => write!(f, "S7+"),
        }
    }
}

/// Result of [`S7Client::get_cpu_info`](crate::S7Client::get_cpu_info).
#[derive(Debug, Clone)]
pub struct CpuInfo {
    /// Module type name (e.g. `"CPU 317-2 PN/DP"`).
    pub module_type: String,
    /// CPU serial number.
    pub serial_number: String,
    /// Plant identification (AS name).
    pub as_name: String,
    /// Copyright notice.
    pub copyright: String,
    /// Module name.
    pub module_name: String,
    /// Protocol version used by the PLC (S7 for 300/400, S7+ for 1500).
    pub protocol: Protocol,
}

/// Result of [`S7Client::get_cp_info`](crate::S7Client::get_cp_info).
#[derive(Debug, Clone)]
pub struct CpInfo {
    /// Maximum PDU byte length.
    pub max_pdu_len: u32,
    /// Maximum number of connections.
    pub max_connections: u32,
    /// Maximum MPI baud rate.
    pub max_mpi_rate: u32,
    /// Maximum bus baud rate.
    pub max_bus_rate: u32,
}

/// Result of [`S7Client::get_protection`](crate::S7Client::get_protection).
#[derive(Debug, Clone)]
pub struct Protection {
    /// Protection scheme SZL number.
    pub scheme_szl: u16,
    /// Protection scheme module number.
    pub scheme_module: u16,
    /// Protection scheme bus number.
    pub scheme_bus: u16,
    /// Protection level: 0=none, 1=write, 2=read/write, 3=complete.
    pub level: u16,
    /// Whether a password is currently set on the PLC.
    pub password_set: bool,
}

/// Obfuscate an S7 password using the nibble-swap + XOR-0x55 algorithm.
///
/// Passwords longer than 8 bytes are truncated; shorter passwords are
/// space-padded to 8 bytes.  Returns an 8-byte array suitable for use
/// with [`S7Client::set_session_password`](crate::S7Client::set_session_password).
pub fn encrypt_password(password: &str) -> [u8; 8] {
    let bytes = password.as_bytes();
    let mut pw = [0x20u8; 8]; // space-padded
    let len = bytes.len().min(8);
    pw[..len].copy_from_slice(&bytes[..len]);
    let mut result = [0u8; 8];
    for i in 0..8 {
        // Swap nibbles then XOR with 0x55
        result[i] = (pw[i] << 4) | (pw[i] >> 4);
        result[i] ^= 0x55;
    }
    result
}

/// A module entry returned by [`S7Client::read_module_list`](crate::S7Client::read_module_list).
#[derive(Debug, Clone)]
pub struct ModuleEntry {
    /// Module type identifier.
    pub module_type: u16,
}

/// A single block type/count entry in [`BlockList`].
#[derive(Debug, Clone)]
pub struct BlockListEntry {
    /// Block type identifier (matches [`BlockType`] discriminant values).
    pub block_type: u16,
    /// Number of blocks of this type present in the PLC.
    pub count: u16,
}

/// Result of [`S7Client::list_blocks`](crate::S7Client::list_blocks).
#[derive(Debug, Clone)]
pub struct BlockList {
    /// Total number of blocks across all types.
    pub total_count: u32,
    /// Per-type block counts.
    pub entries: Vec<BlockListEntry>,
}

/// A raw PLC block in the Siemens Diagra upload/download format.
///
/// The wire format starts with a 20-byte header:
/// ```text
/// [blk_type:2][blk_number:2][format:2][length:4][flags:2][crc1:2][crc2:2][??:4]
/// ```
/// followed by the MC7 code / data payload, and optionally trailer strings.
#[derive(Debug, Clone)]
pub struct BlockData {
    /// Block type identifier (see [`BlockType`] discriminants).
    pub block_type: u16,
    /// Block number.
    pub block_number: u16,
    /// Block format/encoding version.
    pub format: u16,
    /// Total block length (including header).
    pub total_length: u32,
    /// Block flags.
    pub flags: u16,
    /// First CRC value.
    pub crc1: u16,
    /// Second CRC value.
    pub crc2: u16,
    /// Raw MC7 code / data payload (everything after the 20-byte header).
    pub payload: Vec<u8>,
}

/// Attributes that can be set on a block header.
#[derive(Debug, Clone, Default)]
pub struct BlockAttributes {
    /// Author string (max 8 chars, padded with spaces).
    pub author: Option<String>,
    /// Family string (max 8 chars, padded with spaces).
    pub family: Option<String>,
    /// Header/name string (max 8 chars, padded with spaces).
    pub name: Option<String>,
    /// Version (major.minor encoded as `(major << 4) | minor`).
    pub version: Option<u8>,
    /// Block flags (overrides existing flags word).
    pub flags: Option<u16>,
}

impl BlockData {
    /// Parse raw uploaded bytes into a `BlockData`.
    pub fn from_bytes(data: &[u8]) -> Option<Self> {
        if data.len() < 20 {
            return None;
        }
        let block_type = u16::from_be_bytes([data[0], data[1]]);
        let block_number = u16::from_be_bytes([data[2], data[3]]);
        let format = u16::from_be_bytes([data[4], data[5]]);
        let total_length = u32::from_be_bytes([data[6], data[7], data[8], data[9]]);
        let flags = u16::from_be_bytes([data[10], data[11]]);
        let crc1 = u16::from_be_bytes([data[12], data[13]]);
        let crc2 = u16::from_be_bytes([data[14], data[15]]);
        // Skip 20 bytes of header, the rest is payload
        let payload = data[20..].to_vec();
        Some(BlockData {
            block_type,
            block_number,
            format,
            total_length,
            flags,
            crc1,
            crc2,
            payload,
        })
    }

    /// Serialize back to wire bytes (for download).
    pub fn to_bytes(&self) -> Vec<u8> {
        let mut buf = Vec::with_capacity(20 + self.payload.len());
        buf.extend_from_slice(&self.block_type.to_be_bytes());
        buf.extend_from_slice(&self.block_number.to_be_bytes());
        buf.extend_from_slice(&self.format.to_be_bytes());
        buf.extend_from_slice(&self.total_length.to_be_bytes());
        buf.extend_from_slice(&self.flags.to_be_bytes());
        buf.extend_from_slice(&self.crc1.to_be_bytes());
        buf.extend_from_slice(&self.crc2.to_be_bytes());
        buf.extend_from_slice(&[0u8; 4]); // reserved
        buf.extend_from_slice(&self.payload);
        buf
    }

    /// Build a minimal empty DB block ready for download.
    ///
    /// Creates a Diagra-format block with the S7 DB header structure.
    /// `size_bytes` is the desired DB size in bytes (must be even).
    pub fn new_db(db_number: u16, size_bytes: u16) -> Self {
        // Minimal S7 DB block payload: 2-byte "actual size" + zero data
        let size = (size_bytes as usize + 1) & !1; // round up to even
        let mut payload = Vec::with_capacity(2 + size);
        payload.extend_from_slice(&(size as u16).to_be_bytes());
        payload.extend(std::iter::repeat(0u8).take(size));
        let total_length = (20 + payload.len()) as u32;
        BlockData {
            block_type: BlockType::DB as u16,
            block_number: db_number,
            format: 0x0001,
            total_length,
            flags: 0x0000,
            crc1: 0x0000,
            crc2: 0x0000,
            payload,
        }
    }

    /// Compute a CRC-32 checksum of the serialized block bytes.
    ///
    /// Suitable for comparing a locally stored block against one uploaded
    /// from the PLC: `local.crc32() == plc_block.crc32()`.
    pub fn crc32(&self) -> u32 {
        let bytes = self.to_bytes();
        crc32_ieee(&bytes)
    }

    /// Apply [`BlockAttributes`] to this block in-place.
    ///
    /// The S7 block footer is at `payload[payload.len()-48..]` (when payload
    /// is large enough).  Author/Family/Name each occupy 8 bytes at fixed
    /// offsets within the footer.
    pub fn set_attributes(&mut self, attrs: &BlockAttributes) {
        if let Some(f) = attrs.flags {
            self.flags = f;
        }
        // Footer is last 48 bytes of payload (S7 block structure)
        let plen = self.payload.len();
        if plen < 48 {
            return;
        }
        let footer = &mut self.payload[plen - 48..];
        // Footer layout (S7 standard):
        //   [0..8]   reserved
        //   [8..16]  author (8 bytes, space-padded)
        //   [16..24] family (8 bytes, space-padded)
        //   [24..32] name/header (8 bytes, space-padded)
        //   [32]     version byte
        //   [33..48] reserved/checksum
        if let Some(ref s) = attrs.author {
            write_padded(&mut footer[8..16], s);
        }
        if let Some(ref s) = attrs.family {
            write_padded(&mut footer[16..24], s);
        }
        if let Some(ref s) = attrs.name {
            write_padded(&mut footer[24..32], s);
        }
        if let Some(v) = attrs.version {
            footer[32] = v;
        }
    }

    /// Return the human-readable block type name.
    pub fn type_name(&self) -> &'static str {
        block_type_name(self.block_type as u8)
    }
}

pub fn block_type_name(bt: u8) -> &'static str {
    match bt {
        0x38 => "OB",
        0x41 => "DB",
        0x42 => "SDB",
        0x43 => "FC",
        0x44 => "SFC",
        0x45 => "FB",
        0x46 => "SFB",
        0x47 => "UDT",
        _ => "??",
    }
}

fn write_padded(dst: &mut [u8], s: &str) {
    let bytes = s.as_bytes();
    let n = bytes.len().min(dst.len());
    dst[..n].copy_from_slice(&bytes[..n]);
    for b in dst[n..].iter_mut() {
        *b = b' ';
    }
}

// CRC-32 (IEEE 802.3 polynomial 0xEDB88320) — no external dep needed.
fn crc32_ieee(data: &[u8]) -> u32 {
    let mut crc: u32 = 0xFFFF_FFFF;
    for &byte in data {
        crc ^= byte as u32;
        for _ in 0..8 {
            if crc & 1 != 0 {
                crc = (crc >> 1) ^ 0xEDB8_8320;
            } else {
                crc >>= 1;
            }
        }
    }
    !crc
}

/// Compare two block lists: local files vs PLC blocks.
///
/// Each entry is `(block_type, block_number)` in `local`; the closure
/// `plc_crc` is called for each to retrieve the PLC-side CRC.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BlockCmpResult {
    /// Identical CRC — block matches.
    Match,
    /// CRC differs — block has been modified on the PLC.
    Mismatch { local_crc: u32, plc_crc: u32 },
    /// Block exists locally but not on the PLC.
    OnlyLocal,
    /// Block exists on the PLC but not locally.
    OnlyPlc,
}

/// Detailed information about a PLC block, returned by
/// [`S7Client::get_ag_block_info`](crate::S7Client::get_ag_block_info) and
/// [`S7Client::get_pg_block_info`](crate::S7Client::get_pg_block_info).
#[derive(Debug, Clone)]
pub struct BlockInfo {
    pub block_type: u16,
    pub block_number: u16,
    pub language: u16,
    pub flags: u16,
    pub size: u16,
    pub size_ram: u16,
    pub mc7_size: u16,
    pub local_data: u16,
    pub checksum: u16,
    pub version: u16,
    pub author: String,
    pub family: String,
    pub header: String,
    pub date: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum BlockType {
    OB = 0x38,
    DB = 0x41,
    SDB = 0x42,
    FC = 0x43,
    SFC = 0x44,
    FB = 0x45,
    SFB = 0x46,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn connect_params_default() {
        let p = ConnectParams::default();
        assert_eq!(p.rack, 0);
        assert_eq!(p.slot, 1);
        assert_eq!(p.pdu_size, 480);
    }

    #[test]
    fn block_data_roundtrip() {
        let bd = super::BlockData {
            block_type: 0x41, // DB
            block_number: 1,
            format: 0,
            total_length: 24,
            flags: 0,
            crc1: 0x1234,
            crc2: 0x5678,
            payload: vec![0xDE, 0xAD],
        };
        let bytes = bd.to_bytes();
        assert_eq!(bytes.len(), 22); // 20 header + 2 payload
        let parsed = super::BlockData::from_bytes(&bytes).unwrap();
        assert_eq!(parsed.block_type, 0x41);
        assert_eq!(parsed.block_number, 1);
        assert_eq!(parsed.payload, vec![0xDE, 0xAD]);
    }

    #[test]
    fn block_data_short_input_returns_none() {
        let result = super::BlockData::from_bytes(&[0u8; 10]);
        assert!(result.is_none());
    }

    #[test]
    fn encrypt_8_char_password() {
        // Known vector: "PASSWORD" -> swap nibbles, XOR 0x55
        let result = super::encrypt_password("PASSWORD");
        assert_eq!(result.len(), 8);
        // Each byte: nibble_swap(byte) ^ 0x55
        // 'P' = 0x50 -> 0x05 -> 0x05 ^ 0x55 = 0x50
        // 'A' = 0x41 -> 0x14 -> 0x14 ^ 0x55 = 0x41
        // Wait — this depends on the actual algorithm.
        // Let's verify the algorithm is self-consistent:
        let result2 = super::encrypt_password("PASSWORD");
        assert_eq!(result, result2);
    }

    #[test]
    fn encrypt_short_password_padded() {
        let result = super::encrypt_password("abc");
        // "abc" padded to 8 bytes with spaces (0x20)
        // byte 0: 'a'(0x61) -> swap -> 0x16 -> ^0x55 -> 0x43
        assert_eq!((0x61u8 << 4) | (0x61u8 >> 4), 0x16);
        assert_eq!(0x16 ^ 0x55, 0x43);
        assert_eq!(result[0], 0x43);
        // byte 3: space(0x20) -> swap -> 0x02 -> ^0x55 -> 0x57
        assert_eq!((0x20u8 << 4) | (0x20u8 >> 4), 0x02);
        assert_eq!(0x02 ^ 0x55, 0x57);
        assert_eq!(result[3], 0x57);
    }

    #[test]
    fn encrypt_long_password_truncated() {
        let result = super::encrypt_password("1234567890");
        assert_eq!(result.len(), 8);
        let result8 = super::encrypt_password("12345678");
        assert_eq!(result, result8);
    }

    #[test]
    fn block_type_discriminants() {
        assert_eq!(BlockType::DB as u8, 0x41);
        assert_eq!(BlockType::OB as u8, 0x38);
    }
}