Merge pull request '[s7] S7 — LOGO!/S7-200 V-memory parser' (#340) from auto/s7/PR-S7-A5 into auto/driver-gaps

This commit was merged in pull request #340.
This commit is contained in:
2026-04-25 17:00:59 -04:00
3 changed files with 121 additions and 6 deletions

View File

@@ -1,3 +1,5 @@
using S7NetCpuType = global::S7.Net.CpuType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
@@ -31,7 +33,7 @@ public enum S7Size
}
/// <summary>
/// Parsed form of an S7 tag-address string. Produced by <see cref="S7AddressParser.Parse"/>.
/// Parsed form of an S7 tag-address string. Produced by <see cref="S7AddressParser.Parse(string)"/>.
/// </summary>
/// <param name="Area">Memory area (DB, M, I, Q, T, C).</param>
/// <param name="DbNumber">Data block number; only meaningful when <paramref name="Area"/> is <see cref="S7Area.DataBlock"/>.</param>
@@ -74,7 +76,29 @@ public static class S7AddressParser
/// the offending input echoed in the message so operators can correlate to the tag
/// config that produced the fault.
/// </summary>
public static S7ParsedAddress Parse(string address)
/// <remarks>
/// The CPU-agnostic overload rejects the <c>V</c> area letter; <c>V</c> is only
/// meaningful on S7-200 / S7-200 Smart / LOGO! where it maps to a fixed DB number
/// (DB1 by convention) — call <see cref="Parse(string, S7NetCpuType?)"/> with the
/// device's CPU family for V-memory tags.
/// </remarks>
public static S7ParsedAddress Parse(string address) => Parse(address, cpuType: null);
/// <summary>
/// Parse an S7 address with knowledge of the device's CPU family. Required for the
/// <c>V</c> area letter (S7-200 / S7-200 Smart / LOGO! V-memory), which maps to
/// DataBlock DB1 on those families. On S7-300 / S7-400 / S7-1200 / S7-1500 the
/// <c>V</c> letter is rejected because it has no equivalent — those families use
/// explicit <c>DB{n}.DB...</c> addressing.
/// </summary>
/// <remarks>
/// LOGO! firmware bands map V-memory to different underlying DB numbers in some
/// 0BA editions; the driver currently uses DB1 (the most common LOGO! 8 / 0BA8
/// mapping). If a future site ships a firmware band where VM lives in a different
/// DB, the mapping table in <see cref="VMemoryDbNumberFor"/> is the single point
/// to extend. Live LOGO! testing is out of scope for the initial PR.
/// </remarks>
public static S7ParsedAddress Parse(string address, S7NetCpuType? cpuType)
{
if (string.IsNullOrWhiteSpace(address))
throw new FormatException("S7 address must not be empty");
@@ -97,21 +121,28 @@ public static class S7AddressParser
case 'Q': return ParseMIQ(S7Area.Output, rest, address);
case 'T': return ParseTimerOrCounter(S7Area.Timer, rest, address);
case 'C': return ParseTimerOrCounter(S7Area.Counter, rest, address);
case 'V': return ParseV(rest, address, cpuType);
default:
throw new FormatException($"S7 address '{address}' starts with unknown area '{areaChar}' (expected DB/M/I/Q/T/C)");
throw new FormatException($"S7 address '{address}' starts with unknown area '{areaChar}' (expected DB/M/I/Q/T/C/V)");
}
}
/// <summary>
/// Try-parse variant for callers that can't afford an exception on bad input (e.g.
/// config validation pages in the Admin UI). Returns <c>false</c> for any input that
/// would throw from <see cref="Parse"/>.
/// would throw from <see cref="Parse(string)"/>.
/// </summary>
public static bool TryParse(string address, out S7ParsedAddress result)
=> TryParse(address, cpuType: null, out result);
/// <summary>
/// Try-parse variant that accepts a CPU family for V-memory addressing.
/// </summary>
public static bool TryParse(string address, S7NetCpuType? cpuType, out S7ParsedAddress result)
{
try
{
result = Parse(address);
result = Parse(address, cpuType);
return true;
}
catch (FormatException)
@@ -206,6 +237,46 @@ public static class S7AddressParser
return new S7ParsedAddress(area, DbNumber: 0, size, byteOffset, bitOffset);
}
/// <summary>
/// Parse a <c>V</c>-area address (S7-200 / S7-200 Smart / LOGO! V-memory). Same width
/// suffixes as M/I/Q (<c>VB</c>, <c>VW</c>, <c>VD</c>, <c>V0.0</c>) but rewritten as
/// a DataBlock access so the rest of the driver — which speaks S7.Net's DB-centric
/// API — needs no special-casing downstream.
/// </summary>
private static S7ParsedAddress ParseV(string rest, string original, S7NetCpuType? cpuType)
{
var dbNumber = VMemoryDbNumberFor(cpuType, original);
// Reuse the M/I/Q grammar — V's size suffixes are identical (B/W/D/LD or .bit).
var parsed = ParseMIQ(S7Area.Memory, rest, original);
return parsed with { Area = S7Area.DataBlock, DbNumber = dbNumber };
}
/// <summary>
/// Map a CPU family to the underlying DB number that backs V-memory. Returns DB1
/// for S7-200, S7-200 Smart, and LOGO! 0BA8 (the only LOGO! the S7.Net <c>CpuType</c>
/// enum surfaces). Throws for families that have no V-area concept.
/// </summary>
private static int VMemoryDbNumberFor(S7NetCpuType? cpuType, string original)
{
if (cpuType is null)
throw new FormatException(
$"S7 V-memory address '{original}' requires a CPU family (S7-200 / S7-200 Smart / LOGO!) — " +
"the CPU-agnostic Parse overload cannot resolve V-memory to a DB number");
return cpuType.Value switch
{
S7NetCpuType.S7200 => 1,
S7NetCpuType.S7200Smart => 1,
// LOGO! 8 / 0BA8 firmware bands typically expose VM as DB1 over S7comm. Older
// 0BA editions can differ; the mapping is centralised here for easy extension
// once a site provides a non-DB1 firmware band to test against.
S7NetCpuType.Logo0BA8 => 1,
_ => throw new FormatException(
$"S7 V-memory address '{original}' is only valid on S7-200 / S7-200 Smart / LOGO! " +
$"(got CpuType={cpuType.Value}); use explicit DB{{n}}.DB... addressing on this family"),
};
}
private static S7ParsedAddress ParseTimerOrCounter(S7Area area, string rest, string original)
{
if (rest.Length == 0)

View File

@@ -102,7 +102,9 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
_parsedByName.Clear();
foreach (var t in _options.Tags)
{
var parsed = S7AddressParser.Parse(t.Address); // throws FormatException
// Pass CpuType so V-memory addresses (S7-200 / S7-200 Smart / LOGO!) resolve
// against the device's family-specific DB mapping.
var parsed = S7AddressParser.Parse(t.Address, _options.CpuType); // throws FormatException
if (t.ElementCount is int n && n > 1)
{
// Array sanity: cap at S7 PDU realistic limit, reject variable-width

View File

@@ -1,5 +1,6 @@
using Shouldly;
using Xunit;
using S7NetCpuType = global::S7.Net.CpuType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
@@ -121,4 +122,45 @@ public sealed class S7AddressParserTests
r.DbNumber.ShouldBe(1);
r.Size.ShouldBe(S7Size.Word);
}
// --- V-memory (S7-200 / S7-200 Smart / LOGO!) ---
[Theory]
[InlineData("VB0", S7Size.Byte, 0, 0)]
[InlineData("VW0", S7Size.Word, 0, 0)]
[InlineData("VD4", S7Size.DWord, 4, 0)]
[InlineData("V0.0", S7Size.Bit, 0, 0)]
[InlineData("V10.7", S7Size.Bit, 10, 7)]
public void Parse_V_memory_maps_to_DB1_for_S7200(string input, S7Size size, int byteOff, int bitOff)
{
var r = S7AddressParser.Parse(input, S7NetCpuType.S7200);
r.Area.ShouldBe(S7Area.DataBlock);
r.DbNumber.ShouldBe(1);
r.Size.ShouldBe(size);
r.ByteOffset.ShouldBe(byteOff);
r.BitOffset.ShouldBe(bitOff);
}
[Theory]
[InlineData(S7NetCpuType.S7200Smart)]
[InlineData(S7NetCpuType.Logo0BA8)]
public void Parse_V_memory_maps_to_DB1_for_S7200Smart_and_LOGO(S7NetCpuType cpu)
{
var r = S7AddressParser.Parse("VW0", cpu);
r.Area.ShouldBe(S7Area.DataBlock);
r.DbNumber.ShouldBe(1);
r.Size.ShouldBe(S7Size.Word);
}
[Theory]
[InlineData(S7NetCpuType.S71500)]
[InlineData(S7NetCpuType.S71200)]
[InlineData(S7NetCpuType.S7300)]
[InlineData(S7NetCpuType.S7400)]
public void Parse_V_memory_rejected_on_modern_families(S7NetCpuType cpu)
=> Should.Throw<FormatException>(() => S7AddressParser.Parse("VW0", cpu));
[Fact]
public void Parse_V_memory_rejected_when_no_CpuType_supplied()
=> Should.Throw<FormatException>(() => S7AddressParser.Parse("VW0"));
}