Merge pull request '[s7] S7 — Symbol-table / TIA Portal export browse' (#393) from auto/s7/PR-S7-D1 into auto/driver-gaps

This commit was merged in pull request #393.
This commit is contained in:
2026-04-26 06:35:00 -04:00
20 changed files with 2526 additions and 0 deletions

View File

@@ -136,3 +136,42 @@ otopcua-s7-cli subscribe -h 192.168.1.30 -a DB1.DBW0 -t Int16 -i 500
S7comm has no native push — the CLI polls through `PollGroupEngine` just like S7comm has no native push — the CLI polls through `PollGroupEngine` just like
Modbus / AB. Modbus / AB.
### `import-symbols`
PR-S7-D1 / [#299](https://github.com/dohertj2/lmxopcua/issues/299) — read a TIA
Portal CSV ("Show all tags" export) or STEP 7 Classic `.AWL` file and emit a
JSON tag fragment for `appsettings.json`, or a one-line summary. Mirrors the
AB Legacy `import-rslogix` CLI in shape.
```powershell
# TIA Portal CSV — emit JSON fragment to stdout
otopcua-s7-cli import-symbols --file plc-export.csv --format tia
# STEP 7 Classic AWL — emit summary line
otopcua-s7-cli import-symbols --file classic.awl --format awl --emit summary
# DE-locale CSV — auto-detected; output to file
otopcua-s7-cli import-symbols `
--file plc-de.csv `
--format tia `
--emit appsettings-fragment `
--output tags.json
# Strict mode — fail-fast on the first malformed row (CI lint)
otopcua-s7-cli import-symbols --file plc.csv --format tia --strict
```
| Flag | Default | Purpose |
|---|---|---|
| `-f` / `--file` | **required** | Path to the TIA CSV or `.AWL` file |
| `--format` | `tia` | `tia` (CSV) or `awl` (STEP 7 Classic) |
| `-d` / `--device` | none | Optional documentation tag (held for symmetry with `import-rslogix`) |
| `--emit` | `appsettings-fragment` | `appsettings-fragment` (JSON) or `summary` (one-line counter) |
| `-o` / `--output` | stdout | Optional path; when set the JSON fragment is written there + summary line goes to stdout |
| `--max-rows` | unlimited | Defensive cap on rows imported |
| `--strict` | off | Fail-fast on the first malformed row (default permissive: skip + log) |
UDT-typed rows import as placeholder tags (data type forced to `Byte`); see
[S7-TIA-Import.md](drivers/S7-TIA-Import.md) for the full format reference,
locale auto-detection, and AWL position-based addressing rules.

View File

@@ -0,0 +1,215 @@
# S7 — TIA Portal CSV & STEP 7 Classic AWL symbol import
PR-S7-D1 / [#299](https://github.com/dohertj2/lmxopcua/issues/299) — bulk-import
TIA Portal "Show all tags" CSV exports and STEP 7 Classic AWL declaration files
into the S7 driver. Saves operators from hand-typing every `%MW0` /
`%DB1.DBW0` row of a several-hundred-tag PLC into `appsettings.json`.
## Supported formats — v1
| Format | Status | Notes |
|---|---|---|
| TIA Portal `.CSV` ("Show all tags" export) | **supported** | Header columns `Name,Path,Data type,Logical address,Comment,Hmi accessible,…`; en-US (`,`) and DE-locale (`;` separator + `,` decimal) auto-detected |
| STEP 7 Classic `.AWL` (`VAR_GLOBAL` + `DATA_BLOCK`) | **supported, best-effort** | Position-based offset assignment (no exact byte offsets in hand-exported AWL — see below) |
| STEP 7 / TIA Portal native binary (`.s7p`, `.zap`) | **out of scope** | Proprietary; no community parser. Use TIA's "Show all tags" CSV export |
| TIA Portal Openness API | **out of scope** | Requires a licensed TIA install + OpenAPI license; future PR |
## TIA Portal CSV column reference
| Column | Required | Notes |
|---|---|---|
| `Name` | yes | OPC UA tag name. TIA symbols are stable across deployments; the importer uses them verbatim |
| `Logical address` (or `Address`) | yes | TIA-style address with leading `%` (e.g. `%MW0`, `%DB1.DBW10`, `%DB1.DBX2.3`). Stripped on import |
| `Data type` | recommended | TIA primitive type (`Int`, `Real`, `Bool`, `String`, …) — drives the imported `S7DataType` |
| `Comment` | no | Parsed but currently unused — `S7TagDefinition` has no `Description` field at the v2 schema layer (see [#248](https://github.com/dohertj2/lmxopcua/issues/248)). Held in the column contract for future schema bumps |
| `Hmi accessible` | no | Filter — rows with `False` / `FALSCH` / `nein` are skipped (internal symbols TIA shows in the editor but doesn't expose to client interfaces). Missing column defaults to `True` |
| `Hmi visible` / `Hmi writeable` | no | Currently unused — held for future Admin-UI-side metadata |
| `Length` | no | For `String` rows: max length. Default 254. Drives `StringLength` on the imported tag |
| `Path` | no | TIA tag-table path (`Default tag table`, custom names). Currently unused; held in the contract |
### TIA `Data type` → `S7DataType` mapping
| TIA type | Maps to | Notes |
|---|---|---|
| `Bool` | `Bool` | Bit access; address must include a `.bit` suffix |
| `Byte`, `SInt`, `USInt` | `Byte` | 1-byte unsigned/signed |
| `Int` | `Int16` | Signed 16-bit |
| `Word`, `UInt` | `UInt16` | Unsigned 16-bit |
| `DInt` | `Int32` | Signed 32-bit |
| `DWord`, `UDInt` | `UInt32` | Unsigned 32-bit |
| `LInt` | `Int64` | 64-bit signed (S7-1500 only) |
| `LWord`, `ULInt` | `UInt64` | 64-bit unsigned (S7-1500 only) |
| `Real` | `Float32` | IEEE-754 32-bit |
| `LReal` | `Float64` | IEEE-754 64-bit (S7-1500 only) |
| `String` | `String` | S7 STRING with 2-byte header; `Length` column drives `StringLength` |
| `WString` | `WString` | S7 WSTRING (UTF-16BE) |
| `Char` / `WChar` | `Char` / `WChar` | Single-character |
| `Date` | `Date` | UInt16 days since 1990-01-01 |
| `Time` | `Time` | Int32 ms |
| `TOD` / `Time_Of_Day` | `TimeOfDay` | UInt32 ms since midnight |
| `DT` / `Date_And_Time` | `DateAndTime` | 8-byte BCD |
| `DTL` | `Dtl` | 12-byte structured (S7-1200 / S7-1500) |
| `S5Time` | `S5Time` | 16-bit BCD duration |
| `Struct` / quoted UDT name | UDT placeholder | See below |
### UDT placeholders
UDT-typed symbols (TIA `Data type` = `"MyUdt"` quoted, or the literal `Struct`)
import as a **placeholder** — the resulting tag lands in the driver options so
it shows up in the Admin UI tag list, but its data type is forced to `Byte`
and the row is marked `Writable = false`. PR-S7-D2 will replace the placeholder
with proper UDT layout once the symbol table covers nested struct fields.
`S7ImportResult.UdtPlaceholderCount` tracks how many of the imported tags
landed in this bucket.
## DE locale handling
TIA Portal honours the Windows display locale when writing CSV. A DE-locale
install emits:
- Field separator `;` (because `,` is the decimal separator)
- Decimal-comma in addresses: `%MW0,5` rather than `%MW0.5` for bit addresses
- Boolean column values `WAHR` / `FALSCH` rather than `True` / `False`
The importer **auto-detects** the locale from the first non-blank line:
- Field-separator detection: counts `;` vs `,` occurrences in the header
- Decimal-comma detection: scans the first data row's address column for a
digit-comma-digit pattern
- Boolean column values: recognises both languages (`true/false/wahr/falsch/yes/no/ja/nein`,
case-insensitive) plus bare `0`/`1`
The address column is rewritten to en-US shape (`%MW0,5``MW0.5`) before the
strict `S7AddressParser` runs, so the rest of the driver pipeline sees a
single canonical address shape.
## STEP 7 Classic AWL — `VAR_GLOBAL` + `DATA_BLOCK`
Best-effort parser for legacy STEP 7 Classic projects:
- `VAR_GLOBAL … END_VAR` — global memory area declarations. Each entry maps to
a sequential `M{B|W|D}{offset}` address based on declaration order.
- `DATA_BLOCK DBn … END_DATA_BLOCK` — DB declarations. Each field maps to a
`DB{n}.DB{B|W|D}{offset}` address based on declaration order; the DB number
is parsed from the `DATA_BLOCK` line's `DBn` keyword.
### Position-based addressing — heuristic
Real STEP 7 Classic projects carry exact byte offsets in the symbol table /
.gr8 deployment artefact, but a hand-exported AWL file omits them. The
importer assumes:
| Type | Bytes |
|---|---|
| `BOOL` | 1 (rounded up to byte alignment) |
| `BYTE` / `SINT` / `USINT` / `CHAR` | 1 |
| `INT` / `WORD` / `UINT` | 2 |
| `DINT` / `DWORD` / `UDINT` / `REAL` | 4 |
| `LREAL` / `LINT` / `ULINT` / `LWORD` | 8 |
| `STRING[N]` | N + 2 (2-byte header) |
| `STRING` (no length) | 256 |
| `STRUCT` / `Array[…] of …` / quoted UDT name | UDT placeholder (8-bit Byte at next aligned offset) |
S7 alignment rule: offsets round up to a 2-byte boundary for any 16-bit-or-larger
type. Sites needing exact offsets should drive their symbol import from the
TIA Portal CSV path instead — the CSV carries the offsets verbatim.
Comments (`(* ... *)` block, `// ...` line) are stripped before declaration
parsing. Initial-value clauses (`:= 0`) are recognised and discarded.
## CLI subcommand — `import-symbols`
```powershell
otopcua-s7-cli import-symbols --help
```
| Flag | Default | Purpose |
|---|---|---|
| `-f` / `--file` | **required** | Path to the TIA CSV or `.AWL` file |
| `--format` | `tia` | `tia` (CSV) or `awl` (STEP 7 Classic) |
| `-d` / `--device` | none | Optional documentation tag (reserved for symmetry with `import-rslogix`) |
| `--emit` | `appsettings-fragment` | `appsettings-fragment` (JSON) or `summary` (one-line counter) |
| `-o` / `--output` | stdout | Optional path; when set the JSON fragment is written there + summary line goes to stdout |
| `--max-rows` | unlimited | Defensive cap on rows imported |
| `--strict` | off | Fail-fast on the first malformed row (default permissive: skip + log) |
### `appsettings-fragment` output shape
The default `--emit appsettings-fragment` mode writes a JSON object whose
`Tags` array is shaped like the `S7DriverConfigDto.Tags` array — paste
straight into the driver-instance config under
`Drivers/<instance>/Config/Tags`.
```json
{
"Tags": [
{
"Name": "MotorSpeed",
"Address": "MW0",
"DataType": "Int16",
"Writable": true,
"StringLength": 254
},
]
}
```
### Summary line
`--emit summary` writes a single line:
```
Imported 142 tag(s), skipped 3, errors 0, udt-placeholders 5.
```
`Skipped` covers HMI-accessible-false rows + missing-required-field rows;
`errors` covers rows whose `Address` failed to parse as an S7 address;
`udt-placeholders` covers UDT-typed rows that imported as placeholders.
## API surface — `IS7SymbolImporter` + `AddTiaCsvImport` / `AddAwlImport`
For server-side / bootstrap use-cases the importer is reachable via:
```csharp
using ZB.MOM.WW.OtOpcUa.Driver.S7;
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
var options = new S7DriverOptions { Host = "192.168.1.30", CpuType = CpuType.S71500 };
// Append imported tags onto an existing options object.
var updated = options.AddTiaCsvImport(
path: @"C:\plc\tia-export.csv",
out var result);
Console.WriteLine($"Imported {result.ParsedCount} tags ({result.UdtPlaceholderCount} placeholders)");
// AWL variant — same shape.
var withAwl = updated.AddAwlImport(
path: @"C:\plc\classic.awl",
out var awlResult);
```
For a hand-managed importer instance (e.g. supplying a custom `ILogger`) call
`new TiaCsvImporter(logger).Parse(stream, opts)` or
`new AwlImporter(logger).Parse(stream, opts)` directly.
## Operational notes
- The importers are **additive**`AddTiaCsvImport` / `AddAwlImport` concatenate
onto the existing `Tags` list rather than replacing it. Hand-rolled tags
(system-status variables, computed fields the operator added by hand) survive
a re-import.
- Re-imports are not idempotent — calling `AddTiaCsvImport` twice will produce
duplicate tag rows. Operators are expected to start from a clean options
object or de-duplicate themselves; a future schema rev may add a
`replace=true` switch.
- UDT placeholders surface in the Admin UI as non-writable Byte tags. PR-S7-D2
will replace the placeholder rows with proper UDT layout (one tag per
primitive field); operators should not bind dependent client tags to
placeholder rows because the addresses will be rewritten when D2 lands.
- Description metadata is dropped on the floor today — see the column
reference above. When [#248](https://github.com/dohertj2/lmxopcua/issues/248)
lands a `Description` field on `S7TagDefinition` the importer will start
populating it without further changes to the CSV contract.

View File

@@ -940,6 +940,32 @@ project that rejects PG and accepts OP). That test is documented in
`tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests` but only runs against `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests` but only runs against
real firmware — the pymodbus-style "TSAP simulator" doesn't exist for S7. real firmware — the pymodbus-style "TSAP simulator" doesn't exist for S7.
## Symbol import
PR-S7-D1 / [#299](https://github.com/dohertj2/lmxopcua/issues/299) — bulk-import
TIA Portal "Show all tags" CSV exports and STEP 7 Classic AWL declaration files
into the S7 driver's tag list. Operators no longer hand-edit the
`Drivers/<instance>/Config/Tags` JSON for hundred-tag projects.
Two formats supported v1:
- **TIA Portal CSV** — `Name,Path,Data type,Logical address,Comment,Hmi accessible,…`.
en-US (`,`) and DE-locale (`;` separator + `,` decimal) auto-detected.
HMI-hidden symbols filter out automatically; UDT-typed rows import as
placeholders until PR-S7-D2 ships proper UDT layout.
- **STEP 7 Classic AWL** — `VAR_GLOBAL` + `DATA_BLOCK` declarations parsed
best-effort with position-based offset assignment.
Two surface options:
- **CLI**: `otopcua-s7-cli import-symbols --file foo.csv --format tia` emits
an `appsettings.json` JSON fragment for hand-merge.
- **API**: `S7DriverOptions.AddTiaCsvImport(path, out result)` /
`AddAwlImport(path, out result)` for server-side bootstrap paths.
Full reference: [`docs/drivers/S7-TIA-Import.md`](../drivers/S7-TIA-Import.md).
CLI flag table: [`docs/Driver.S7.Cli.md` "import-symbols"](../Driver.S7.Cli.md#import-symbols).
## References ## References
1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf 1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf

View File

@@ -0,0 +1,127 @@
using System.IO;
using System.Text.Json;
using CliFx;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
/// <summary>
/// PR-S7-D1 / #299 — read a TIA Portal CSV ("Show all tags" export) or a STEP 7 Classic
/// AWL declaration file and emit either an <c>appsettings.json</c> tag fragment or a
/// summary line. Mirrors the AB Legacy <c>import-rslogix</c> command in shape.
/// </summary>
[Command("import-symbols", Description =
"Read a TIA Portal CSV symbol export or STEP 7 Classic AWL file and emit a JSON tag " +
"fragment for appsettings.json. UDT-typed symbols import as placeholders until PR-S7-D2.")]
public sealed class ImportSymbolsCommand : ICommand
{
[CommandOption("file", 'f', Description =
"Path to the symbol export. CSV or .AWL — see --format.",
IsRequired = true)]
public string File { get; init; } = default!;
[CommandOption("format", Description =
"Source format: 'tia' (TIA Portal CSV — Name,Path,Data type,Logical address,…) or " +
"'awl' (STEP 7 Classic VAR_GLOBAL + DATA_BLOCK declarations). Default: 'tia'.")]
public string Format { get; init; } = "tia";
[CommandOption("device", 'd', Description =
"Optional documentation tag for the device the imported symbols belong to. The S7 driver " +
"ties tags to the driver instance (one Host per instance), so the value is currently emitted " +
"as a comment-only annotation in the summary; reserved so the field exists for symmetry " +
"with the AB Legacy import CLI.")]
public string? Device { get; init; }
[CommandOption("emit", Description =
"Output shape: 'appsettings-fragment' (default) emits a JSON object with a Tags array " +
"ready to paste into appsettings.json; 'summary' emits one human-readable counter line.")]
public string Emit { get; init; } = "appsettings-fragment";
[CommandOption("output", 'o', Description =
"Optional output file. When omitted, the result goes to stdout.")]
public string? Output { get; init; }
[CommandOption("max-rows", Description =
"Defensive cap on the number of rows imported. Beyond the cap the parser stops and emits " +
"a warning; useful for dry-running a large export against the CLI.")]
public int? MaxRows { get; init; }
[CommandOption("strict", Description =
"When set, the first malformed row throws and the CLI exits non-zero. Default is " +
"permissive (skip + log).")]
public bool Strict { get; init; }
public async ValueTask ExecuteAsync(IConsole console)
{
if (!System.IO.File.Exists(File))
{
throw new CommandException($"Symbol export file not found: {File}", exitCode: 1);
}
var opts = new S7ImportOptions(
MaxRowsToImport: MaxRows,
IgnoreInvalid: !Strict);
var format = Format?.Trim().ToLowerInvariant() ?? "tia";
IS7SymbolImporter importer = format switch
{
"tia" or "csv" or null or "" => new TiaCsvImporter(),
"awl" or "step7" or "stl" => new AwlImporter(),
_ => throw new CommandException(
$"Unknown --format value '{Format}'. Use 'tia' or 'awl'.", exitCode: 2),
};
S7ImportResult result;
using (var stream = System.IO.File.OpenRead(File))
{
result = importer.Parse(stream, opts);
}
var emit = Emit?.Trim().ToLowerInvariant();
var payload = emit switch
{
"summary" => FormatSummary(result),
"appsettings-fragment" or null or "" => FormatFragment(result),
_ => throw new CommandException(
$"Unknown --emit value '{Emit}'. Use 'appsettings-fragment' or 'summary'.",
exitCode: 2),
};
if (Output is { Length: > 0 })
{
await System.IO.File.WriteAllTextAsync(Output, payload);
await console.Output.WriteLineAsync(
$"Wrote {result.ParsedCount} tag(s) to {Output} (skipped={result.SkippedCount}, errors={result.ErrorCount}, udt={result.UdtPlaceholderCount}).");
}
else
{
await console.Output.WriteLineAsync(payload);
}
}
/// <summary>
/// Serialise the imported tag list as a JSON fragment shaped like the
/// <c>S7DriverConfigDto</c>'s <c>Tags</c> array — drop straight into the
/// <c>appsettings.json</c> driver config under
/// <c>Drivers/&lt;instance&gt;/Config/Tags</c>.
/// </summary>
internal static string FormatFragment(S7ImportResult result)
{
var tags = result.Tags.Select(t => new
{
Name = t.Name,
Address = t.Address,
DataType = t.DataType.ToString(),
Writable = t.Writable,
StringLength = t.StringLength,
}).ToArray();
var doc = new { Tags = tags };
return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
}
private static string FormatSummary(S7ImportResult result) =>
$"Imported {result.ParsedCount} tag(s), skipped {result.SkippedCount}, errors {result.ErrorCount}, udt-placeholders {result.UdtPlaceholderCount}.";
}

View File

@@ -1,6 +1,10 @@
using System.IO;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
using S7NetCpuType = global::S7.Net.CpuType; using S7NetCpuType = global::S7.Net.CpuType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7; namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
@@ -102,6 +106,128 @@ public static class S7DriverFactoryExtensions
DeadbandAbsolute: t.DeadbandAbsolute, DeadbandAbsolute: t.DeadbandAbsolute,
DeadbandPercent: t.DeadbandPercent); DeadbandPercent: t.DeadbandPercent);
/// <summary>
/// PR-S7-D1 / #299 — append TIA Portal "Show all tags" CSV rows to
/// <paramref name="options"/> as <see cref="S7TagDefinition"/> entries. Returns a new
/// <see cref="S7DriverOptions"/> with the imported tags concatenated onto the existing
/// <c>Tags</c> list — useful both at startup-time (server-side bootstrap that wants
/// to seed a device's address space from a customer-supplied CSV) and from the CLI
/// (<c>import-symbols</c> emits the resulting JSON fragment for hand-merging into an
/// appsettings file).
/// </summary>
/// <remarks>
/// <para>
/// The importer is permissive by default — malformed rows are logged and skipped;
/// the resulting <see cref="S7ImportResult"/> counts surface on
/// <paramref name="result"/> for callers that want to assert "we got the row count
/// we expected".
/// </para>
/// <para>
/// UDT-typed rows materialise as placeholder tags (data type forced to
/// <see cref="S7DataType.Byte"/>); PR-S7-D2 will replace the placeholders with
/// proper UDT layout. See <c>docs/drivers/S7-TIA-Import.md</c>.
/// </para>
/// </remarks>
public static S7DriverOptions AddTiaCsvImport(
this S7DriverOptions options,
string path,
out S7ImportResult result,
S7ImportOptions? importOptions = null,
ILogger<TiaCsvImporter>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(path);
using var stream = File.OpenRead(path);
var importer = new TiaCsvImporter(logger ?? NullLogger<TiaCsvImporter>.Instance);
result = importer.Parse(stream, importOptions);
return MergeImportedTags(options, result.Tags);
}
/// <summary>
/// CLI-friendly overload that returns the <see cref="S7ImportResult"/> alongside the
/// modified options as a tuple. Mirrors <see cref="AddTiaCsvImport"/> but avoids the
/// <c>out</c> parameter for call sites that prefer pattern-matched destructuring.
/// </summary>
public static (S7DriverOptions Options, S7ImportResult Result) AddTiaCsvImportWithResult(
this S7DriverOptions options,
string path,
S7ImportOptions? importOptions = null,
ILogger<TiaCsvImporter>? logger = null)
{
var updated = options.AddTiaCsvImport(path, out var result, importOptions, logger);
return (updated, result);
}
/// <summary>
/// PR-S7-D1 / #299 — append STEP 7 Classic AWL <c>VAR_GLOBAL</c> + <c>DATA_BLOCK</c>
/// declarations to <paramref name="options"/> as <see cref="S7TagDefinition"/> entries.
/// Best-effort heuristic — see <see cref="AwlImporter"/> for the position-based
/// addressing rules.
/// </summary>
public static S7DriverOptions AddAwlImport(
this S7DriverOptions options,
string path,
out S7ImportResult result,
S7ImportOptions? importOptions = null,
ILogger<AwlImporter>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(path);
using var stream = File.OpenRead(path);
var importer = new AwlImporter(logger ?? NullLogger<AwlImporter>.Instance);
result = importer.Parse(stream, importOptions);
return MergeImportedTags(options, result.Tags);
}
/// <summary>
/// CLI-friendly overload that returns the <see cref="S7ImportResult"/> alongside the
/// modified options as a tuple. Mirrors <see cref="AddAwlImport"/>.
/// </summary>
public static (S7DriverOptions Options, S7ImportResult Result) AddAwlImportWithResult(
this S7DriverOptions options,
string path,
S7ImportOptions? importOptions = null,
ILogger<AwlImporter>? logger = null)
{
var updated = options.AddAwlImport(path, out var result, importOptions, logger);
return (updated, result);
}
/// <summary>
/// Concatenate <paramref name="imported"/> onto <paramref name="options"/>.<see cref="S7DriverOptions.Tags"/>
/// and return a new options object with every other field untouched. The importers
/// are additive so hand-edited Tags rows (e.g., system-status fields not surfaced by
/// the TIA / AWL export) keep sitting alongside the bulk-imported symbol rows.
/// </summary>
private static S7DriverOptions MergeImportedTags(
S7DriverOptions options, IReadOnlyList<S7TagDefinition> imported)
{
var merged = new List<S7TagDefinition>(options.Tags.Count + imported.Count);
merged.AddRange(options.Tags);
merged.AddRange(imported);
return new S7DriverOptions
{
Host = options.Host,
Port = options.Port,
CpuType = options.CpuType,
Rack = options.Rack,
Slot = options.Slot,
Timeout = options.Timeout,
Tags = merged,
Probe = options.Probe,
BlockCoalescingGapBytes = options.BlockCoalescingGapBytes,
TsapMode = options.TsapMode,
LocalTsap = options.LocalTsap,
RemoteTsap = options.RemoteTsap,
ScanGroupIntervals = options.ScanGroupIntervals,
};
}
private static T ParseEnum<T>(string? raw, string driverInstanceId, string field, private static T ParseEnum<T>(string? raw, string driverInstanceId, string field,
string? tagName = null, T? fallback = null) where T : struct, Enum string? tagName = null, T? fallback = null) where T : struct, Enum
{ {

View File

@@ -0,0 +1,434 @@
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
/// <summary>
/// Best-effort AWL (Anweisungsliste / STL — Statement List) importer for legacy STEP 7
/// Classic projects. Recognises two block types:
/// <list type="bullet">
/// <item><c>VAR_GLOBAL</c> … <c>END_VAR</c> — global memory area declarations.
/// Each entry maps to a sequential <c>M{B|W|D}{offset}</c> address based on
/// declaration order.</item>
/// <item><c>DATA_BLOCK "name"</c> … <c>END_DATA_BLOCK</c> — DB declarations.
/// Each field maps to a <c>DB{n}.DB{B|W|D}{offset}</c> address based on
/// declaration order; the DB number is parsed from the <c>DATA_BLOCK</c>
/// line's <c>DB</c> keyword (e.g. <c>DATA_BLOCK DB1</c>).</item>
/// </list>
/// </summary>
/// <remarks>
/// <para>
/// <b>Position-based addressing is heuristic.</b> Real STEP 7 Classic projects
/// carry exact byte offsets in the .gr8 / .awl emitted symbol table, but a hand-
/// exported AWL file omits them. The importer assumes:
/// <list type="bullet">
/// <item>BOOL → 1 byte (rounded up to byte alignment)</item>
/// <item>BYTE / SINT / USINT → 1 byte</item>
/// <item>INT / WORD / UINT → 2 bytes</item>
/// <item>DINT / DWORD / UDINT / REAL → 4 bytes</item>
/// <item>LREAL / LINT / ULINT → 8 bytes</item>
/// <item>STRING — sized by <c>STRING[N]</c> if specified, else 256 (S7 default + 2-byte header)</item>
/// </list>
/// A site needing exact offsets should drive its symbol import from the TIA Portal
/// CSV path instead — <see cref="TiaCsvImporter"/> takes the offsets verbatim from
/// the export.
/// </para>
/// <para>
/// Comments (<c>(* ... *)</c> block, <c>// ...</c> line) are stripped before
/// declaration parsing. Initial-value clauses (<c>:= 0</c>) are recognised and
/// discarded. Multi-line declarations (a single var split across lines) are
/// supported because the parser scans the entire <c>VAR_GLOBAL</c> body as a
/// token stream.
/// </para>
/// </remarks>
public sealed class AwlImporter : IS7SymbolImporter
{
private readonly ILogger<AwlImporter> _logger;
public AwlImporter() : this(NullLogger<AwlImporter>.Instance) { }
public AwlImporter(ILogger<AwlImporter> logger)
{
_logger = logger ?? NullLogger<AwlImporter>.Instance;
}
/// <inheritdoc />
public S7ImportResult Parse(Stream stream, S7ImportOptions? options = null)
{
ArgumentNullException.ThrowIfNull(stream);
var opts = options ?? new S7ImportOptions();
using var reader = new StreamReader(stream, Encoding.UTF8,
detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
var raw = reader.ReadToEnd();
// Strip comments first — keeps the block-walker simpler.
var stripped = StripComments(raw);
var tags = new List<S7TagDefinition>();
var parsed = 0;
var skipped = 0;
var errors = 0;
var udtPlaceholders = 0;
// VAR_GLOBAL block — assign sequential MW offsets.
foreach (var (body, _) in ExtractBlocks(stripped, "VAR_GLOBAL", "END_VAR"))
{
var globalOffset = 0;
foreach (var decl in ExtractDeclarations(body))
{
if (opts.MaxRowsToImport is int cap && parsed >= cap)
{
_logger.LogWarning(
"AWL VAR_GLOBAL hit MaxRowsToImport={Cap}; remaining declarations skipped.", cap);
break;
}
try
{
var (s7Type, sizeBytes, isUdt) = ResolveType(decl.TypeName);
if (isUdt)
{
// UDT placeholder — synthesise a Byte tag at the next aligned offset
// so the operator at least sees the symbol surface in the Admin UI.
globalOffset = AlignTo(globalOffset, 1);
var placeholderAddr = $"MB{globalOffset}";
tags.Add(new S7TagDefinition(
Name: decl.Name,
Address: placeholderAddr,
DataType: S7DataType.Byte,
Writable: false));
udtPlaceholders++;
parsed++;
globalOffset += 1;
_logger.LogInformation(
"AWL VAR_GLOBAL '{Name}' imported as UDT placeholder ({TypeName}). [UDT placeholder — wait for D2]",
decl.Name, decl.TypeName);
continue;
}
if (s7Type is null)
{
if (!opts.IgnoreInvalid)
{
throw new InvalidDataException(
$"AWL VAR_GLOBAL declaration '{decl.Name} : {decl.TypeName}' has unrecognised type.");
}
errors++;
_logger.LogWarning(
"AWL VAR_GLOBAL '{Name}' skipped — unrecognised type '{TypeName}'.",
decl.Name, decl.TypeName);
continue;
}
globalOffset = AlignTo(globalOffset, sizeBytes);
var address = BuildMAddress(s7Type.Value, globalOffset);
tags.Add(new S7TagDefinition(
Name: decl.Name,
Address: address,
DataType: s7Type.Value,
Writable: true));
parsed++;
globalOffset += sizeBytes;
}
catch (InvalidDataException) when (opts.IgnoreInvalid)
{
errors++;
_logger.LogWarning("AWL VAR_GLOBAL declaration '{Name}' skipped — invalid data.", decl.Name);
}
catch (Exception ex) when (opts.IgnoreInvalid)
{
errors++;
_logger.LogWarning(ex, "AWL VAR_GLOBAL declaration '{Name}' skipped — parser threw.", decl.Name);
}
}
}
// DATA_BLOCK "name" / DBn — assign sequential DBW offsets per block.
foreach (var (body, header) in ExtractBlocks(stripped, "DATA_BLOCK", "END_DATA_BLOCK"))
{
var dbNumber = ExtractDbNumber(header);
if (dbNumber is null)
{
_logger.LogWarning(
"AWL DATA_BLOCK header '{Header}' missing DBn — entries skipped.",
header.Trim());
continue;
}
var dbOffset = 0;
// The DB body in real STEP 7 wraps the field declarations in another VAR_TEMP /
// STRUCT block. Walk every nested declaration via the same regex.
foreach (var decl in ExtractDeclarations(body))
{
if (opts.MaxRowsToImport is int cap && parsed >= cap)
{
_logger.LogWarning(
"AWL DATA_BLOCK hit MaxRowsToImport={Cap}; remaining declarations skipped.", cap);
break;
}
try
{
var (s7Type, sizeBytes, isUdt) = ResolveType(decl.TypeName);
if (isUdt)
{
dbOffset = AlignTo(dbOffset, 1);
var placeholderAddr = $"DB{dbNumber.Value}.DBB{dbOffset}";
tags.Add(new S7TagDefinition(
Name: decl.Name,
Address: placeholderAddr,
DataType: S7DataType.Byte,
Writable: false));
udtPlaceholders++;
parsed++;
dbOffset += 1;
continue;
}
if (s7Type is null)
{
if (!opts.IgnoreInvalid)
{
throw new InvalidDataException(
$"AWL DATA_BLOCK DB{dbNumber} declaration '{decl.Name} : {decl.TypeName}' has unrecognised type.");
}
errors++;
_logger.LogWarning(
"AWL DB{DbNumber} '{Name}' skipped — unrecognised type '{TypeName}'.",
dbNumber.Value, decl.Name, decl.TypeName);
continue;
}
dbOffset = AlignTo(dbOffset, sizeBytes);
var address = BuildDbAddress(dbNumber.Value, s7Type.Value, dbOffset);
tags.Add(new S7TagDefinition(
Name: decl.Name,
Address: address,
DataType: s7Type.Value,
Writable: true));
parsed++;
dbOffset += sizeBytes;
}
catch (InvalidDataException) when (opts.IgnoreInvalid)
{
errors++;
_logger.LogWarning("AWL DATA_BLOCK declaration '{Name}' skipped — invalid data.", decl.Name);
}
catch (Exception ex) when (opts.IgnoreInvalid)
{
errors++;
_logger.LogWarning(ex, "AWL DATA_BLOCK declaration '{Name}' skipped — parser threw.", decl.Name);
}
}
}
return new S7ImportResult(tags, parsed, skipped, errors, udtPlaceholders);
}
/// <summary>
/// Strip both <c>(* ... *)</c> block comments and <c>// ...</c> line comments.
/// Block comments don't nest in AWL.
/// </summary>
internal static string StripComments(string input)
{
// Block (* ... *)
var noBlock = Regex.Replace(input, @"\(\*.*?\*\)", " ", RegexOptions.Singleline);
// Line // ...
var noLine = Regex.Replace(noBlock, @"//[^\r\n]*", string.Empty);
return noLine;
}
/// <summary>
/// Yield (body, header-line) pairs for every block bounded by
/// <paramref name="startKeyword"/>/<paramref name="endKeyword"/>. The header line
/// is everything between the start keyword and the first newline, useful for
/// extracting block names / DB numbers.
/// </summary>
internal static IEnumerable<(string Body, string Header)> ExtractBlocks(
string input, string startKeyword, string endKeyword)
{
var idx = 0;
while (idx < input.Length)
{
var startMatch = Regex.Match(input.Substring(idx),
$@"\b{Regex.Escape(startKeyword)}\b", RegexOptions.IgnoreCase);
if (!startMatch.Success) yield break;
var blockStart = idx + startMatch.Index;
// Header = from after the keyword to the first newline.
var headerStart = blockStart + startMatch.Length;
var newlineIdx = input.IndexOf('\n', headerStart);
if (newlineIdx < 0) newlineIdx = input.Length;
var header = input.Substring(headerStart, newlineIdx - headerStart);
// End keyword.
var endIdx = input.IndexOf(endKeyword, newlineIdx, StringComparison.OrdinalIgnoreCase);
if (endIdx < 0)
{
yield break;
}
var body = input.Substring(newlineIdx, endIdx - newlineIdx);
yield return (body, header);
idx = endIdx + endKeyword.Length;
}
}
/// <summary>
/// Extract <c>name : TYPE [ := initial ];</c> declarations from a block body.
/// Permissive — the regex anchors on the colon-and-type pattern so accompanying
/// STRUCT / VAR_TEMP wrappers don't have to be parsed structurally.
/// </summary>
internal static IEnumerable<AwlDeclaration> ExtractDeclarations(string body)
{
// name : TYPE [optional := init];
// Captures: 1=name, 2=type (everything up to := or ;)
// Type may start with " (UDT reference like "MyType"), [ (Array bracket), or a
// bare letter for primitive / struct keywords.
var rx = new Regex(
@"([A-Za-z_][A-Za-z0-9_]*)\s*:\s*((?:[A-Za-z_""\[][\w\[\]\.\,\s\""]*?))\s*(?::=\s*[^;]+)?;",
RegexOptions.Compiled);
// Skip well-known keywords at column-0 like STRUCT / END_STRUCT / VAR_TEMP / etc.
// by filtering on captured names that match an AWL reserved word.
foreach (Match m in rx.Matches(body))
{
var name = m.Groups[1].Value.Trim();
var typeName = m.Groups[2].Value.Trim().TrimEnd(';').Trim();
if (IsReservedAwlWord(name)) continue;
if (string.IsNullOrEmpty(typeName)) continue;
yield return new AwlDeclaration(name, typeName);
}
}
private static bool IsReservedAwlWord(string s) =>
s.ToUpperInvariant() switch
{
"VAR" or "VAR_INPUT" or "VAR_OUTPUT" or "VAR_IN_OUT" or "VAR_TEMP" or
"VAR_GLOBAL" or "END_VAR" or "STRUCT" or "END_STRUCT" or
"DATA_BLOCK" or "END_DATA_BLOCK" or
"TYPE" or "END_TYPE" or
"TITLE" or "VERSION" or "BEGIN" or "ORGANIZATION_BLOCK" or "FUNCTION_BLOCK" or
"FUNCTION" or "END_FUNCTION" or "END_FUNCTION_BLOCK" or "END_ORGANIZATION_BLOCK" => true,
_ => false,
};
/// <summary>
/// Pull the DB number from a <c>DATA_BLOCK</c> header. Accepts both
/// <c>DATA_BLOCK DB1</c> (bare keyword) and <c>DATA_BLOCK "MyDB"</c> with a follow-on
/// <c>// DB1</c> comment-style number — for the v1 we only support the bare
/// <c>DBn</c> form because that's what stock STEP 7 emits.
/// </summary>
internal static int? ExtractDbNumber(string header)
{
var m = Regex.Match(header, @"\bDB\s*(\d+)\b", RegexOptions.IgnoreCase);
if (!m.Success) return null;
return int.Parse(m.Groups[1].Value);
}
/// <summary>
/// Map an AWL type name to (S7 data type, on-wire byte size, isUdt). UDTs and
/// unknown types return (<c>null</c>, <c>0</c>, <c>true</c>) — caller handles them
/// as placeholders.
/// </summary>
internal static (S7DataType? S7Type, int SizeBytes, bool IsUdt) ResolveType(string typeName)
{
var t = typeName.Trim().Trim('"').ToUpperInvariant();
// STRING[N] → S7 String with N+2 byte length.
var strMatch = Regex.Match(t, @"^STRING\s*\[\s*(\d+)\s*\]");
if (strMatch.Success)
{
var len = int.Parse(strMatch.Groups[1].Value);
return (S7DataType.String, len + 2, false);
}
if (t == "STRING") return (S7DataType.String, 256, false);
// Array of UDT or array of primitive — placeholder for now (D2 territory).
if (t.StartsWith("ARRAY", StringComparison.Ordinal))
{
return (null, 0, true);
}
return t switch
{
"BOOL" => (S7DataType.Bool, 1, false),
"BYTE" or "SINT" or "USINT" or "CHAR" => (S7DataType.Byte, 1, false),
"WORD" or "UINT" => (S7DataType.UInt16, 2, false),
"INT" => (S7DataType.Int16, 2, false),
"DWORD" or "UDINT" => (S7DataType.UInt32, 4, false),
"DINT" => (S7DataType.Int32, 4, false),
"REAL" => (S7DataType.Float32, 4, false),
"LREAL" => (S7DataType.Float64, 8, false),
"LINT" => (S7DataType.Int64, 8, false),
"ULINT" or "LWORD" => (S7DataType.UInt64, 8, false),
"TIME" => (S7DataType.Time, 4, false),
"DATE" => (S7DataType.Date, 2, false),
"TOD" or "TIME_OF_DAY" => (S7DataType.TimeOfDay, 4, false),
"DT" or "DATE_AND_TIME" => (S7DataType.DateAndTime, 8, false),
"DTL" => (S7DataType.Dtl, 12, false),
"S5TIME" => (S7DataType.S5Time, 2, false),
"STRUCT" => (null, 0, true),
// Anything else is treated as a UDT reference — surfaces as a placeholder.
_ => (null, 0, IsLikelyUdtName(typeName)),
};
}
private static bool IsLikelyUdtName(string raw)
{
var s = raw.Trim();
// Quoted identifier — TIA / STEP 7 wrap UDT references in double quotes.
if (s.StartsWith('"') && s.EndsWith('"')) return true;
// Bare CamelCase identifier we don't recognise as a primitive — treat as UDT.
if (s.Length > 0 && (char.IsLetter(s[0]) || s[0] == '_')) return true;
return false;
}
/// <summary>
/// Round <paramref name="offset"/> up to a multiple of <paramref name="size"/>.
/// S7 alignment rule: 16-bit values align to 2-byte boundaries; 32/64-bit values
/// align to 2-byte boundaries (NOT 4 — STEP 7 packs DBs at word granularity).
/// We use min(size, 2) for the alignment factor.
/// </summary>
internal static int AlignTo(int offset, int size)
{
var align = size <= 1 ? 1 : 2;
var rem = offset % align;
if (rem == 0) return offset;
return offset + (align - rem);
}
private static string BuildMAddress(S7DataType type, int offset) => type switch
{
S7DataType.Bool => $"M{offset}.0",
S7DataType.Byte => $"MB{offset}",
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.WChar or S7DataType.Date or S7DataType.S5Time
=> $"MW{offset}",
S7DataType.Int32 or S7DataType.UInt32 or S7DataType.Float32 or S7DataType.Time or S7DataType.TimeOfDay
=> $"MD{offset}",
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 or S7DataType.DateAndTime
=> $"MLD{offset}",
_ => $"MB{offset}",
};
private static string BuildDbAddress(int dbNumber, S7DataType type, int offset) => type switch
{
S7DataType.Bool => $"DB{dbNumber}.DBX{offset}.0",
S7DataType.Byte => $"DB{dbNumber}.DBB{offset}",
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.WChar or S7DataType.Date or S7DataType.S5Time
=> $"DB{dbNumber}.DBW{offset}",
S7DataType.Int32 or S7DataType.UInt32 or S7DataType.Float32 or S7DataType.Time or S7DataType.TimeOfDay
=> $"DB{dbNumber}.DBD{offset}",
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 or S7DataType.DateAndTime
=> $"DB{dbNumber}.DBLD{offset}",
S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.Dtl
=> $"DB{dbNumber}.DBB{offset}",
_ => $"DB{dbNumber}.DBB{offset}",
};
/// <summary>One <c>name : TYPE</c> declaration extracted from an AWL block body.</summary>
internal sealed record AwlDeclaration(string Name, string TypeName);
}

View File

@@ -0,0 +1,37 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
/// <summary>
/// Materialises <see cref="S7TagDefinition"/> entries from a vendor-supplied symbol
/// export — TIA Portal CSV (<see cref="TiaCsvImporter"/>) or STEP 7 Classic AWL
/// declaration text (<see cref="AwlImporter"/>). The interface exists so additional
/// formats (e.g. STEP 7 / TIA Portal native binary, openness API, …) can slot in
/// later without reshaping the call sites.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="Parse"/> never throws on per-row parse errors when
/// <see cref="S7ImportOptions.IgnoreInvalid"/> is <c>true</c> (default) — malformed
/// rows are skipped with a structured warning logged via the importer's
/// <c>ILogger</c>, and the counters surface on <see cref="S7ImportResult"/>.
/// With <see cref="S7ImportOptions.IgnoreInvalid"/> set to <c>false</c> the first
/// malformed row throws <see cref="System.IO.InvalidDataException"/>.
/// </para>
/// <para>
/// UDT-typed symbols import as <em>placeholders</em> — the resulting
/// <see cref="S7TagDefinition"/> is well-formed enough to drop into the driver's
/// tag list but it carries the comment marker <c>[UDT placeholder — wait for D2]</c>
/// and an <see cref="S7DataType.Byte"/> data type as a non-functional default.
/// PR-S7-D2 will replace the placeholder with proper UDT layout once the symbol
/// table covers nested struct fields.
/// </para>
/// </remarks>
public interface IS7SymbolImporter
{
/// <summary>
/// Read the entire <paramref name="stream"/> and emit one
/// <see cref="S7TagDefinition"/> per recognised symbol row.
/// </summary>
/// <param name="stream">Open, readable stream over the export. Caller owns it.</param>
/// <param name="options">Filter + safety knobs; <c>null</c> ≡ default options.</param>
S7ImportResult Parse(Stream stream, S7ImportOptions? options = null);
}

View File

@@ -0,0 +1,26 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
/// <summary>
/// Options that drive an <see cref="IS7SymbolImporter"/> run. Two knobs:
/// <list type="bullet">
/// <item>
/// <see cref="MaxRowsToImport"/> — defensive cap; the parser stops past this
/// count and emits a warning so a stray multi-thousand-row export doesn't
/// silently hammer the host process at startup.
/// </item>
/// <item>
/// <see cref="IgnoreInvalid"/> — default <c>true</c>: per-row parse errors are
/// logged and skipped; counter increments. When <c>false</c> the first
/// malformed row surfaces as <see cref="System.IO.InvalidDataException"/> for
/// fail-fast CI lint paths.
/// </item>
/// </list>
/// </summary>
/// <remarks>
/// UDT placeholders are <em>not</em> governed by <see cref="IgnoreInvalid"/> — they're
/// a deliberate import outcome (track the symbol so it lands in the Admin UI tag list
/// even before D2 ships proper UDT layout) and always succeed regardless of the flag.
/// </remarks>
public sealed record S7ImportOptions(
int? MaxRowsToImport = null,
bool IgnoreInvalid = true);

View File

@@ -0,0 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
/// <summary>
/// Outcome of a single <see cref="IS7SymbolImporter"/> run. <see cref="Tags"/> carries
/// the imported tag definitions ready to drop into <c>S7DriverOptions.Tags</c>;
/// <see cref="ParsedCount"/>, <see cref="SkippedCount"/>, <see cref="ErrorCount"/>, and
/// <see cref="UdtPlaceholderCount"/> give the operator a single line of telemetry
/// ("imported 142 / skipped 3 / errored 0 / udt 5") suitable for either a CLI summary
/// or a startup-time log line.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="ParsedCount"/> includes UDT placeholders — placeholders count as
/// imported tags (they materialise as <see cref="S7TagDefinition"/> rows the driver
/// can list in the Admin UI), they're just non-functional until PR-S7-D2 lands.
/// <see cref="UdtPlaceholderCount"/> is a sub-count operators can compare against
/// <see cref="ParsedCount"/> to spot how much of the import is still placeholder.
/// </para>
/// </remarks>
public sealed record S7ImportResult(
IReadOnlyList<S7TagDefinition> Tags,
int ParsedCount,
int SkippedCount,
int ErrorCount,
int UdtPlaceholderCount);

View File

@@ -0,0 +1,514 @@
using System.Globalization;
using System.IO;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
/// <summary>
/// Materialises <see cref="S7TagDefinition"/> entries from a TIA Portal "Show all
/// tags" CSV export. The expected column shape (TIA Portal v15+ default) is
/// <c>Name,Path,Data type,Logical address,Comment,Hmi accessible,Hmi visible,
/// Hmi writeable,Length</c> — only <c>Name</c> + <c>Logical address</c> are strictly
/// required; everything else is optional metadata. Older TIA Portal versions emit a
/// subset (e.g. <c>Name,Address,Data type,Comment</c> for v13) — the parser maps
/// whatever subset the header row carries and tolerates missing optional columns.
/// </summary>
/// <remarks>
/// <para>
/// <b>Locale detection</b>: TIA Portal honours the Windows display locale when
/// writing CSV. A DE-locale install emits comma-decimal addresses
/// (<c>%MW0,5</c>) and <c>WAHR</c> / <c>FALSCH</c> for the boolean HMI columns.
/// The importer detects DE locale by sniffing the first data row's address — if
/// it contains a digit-comma-digit sequence the parser interprets the comma as
/// the decimal separator and rewrites the address to en-US shape (<c>%MW0.5</c>)
/// before parsing. Boolean column values are recognised in both languages
/// (<c>true/false/wahr/falsch/yes/no/ja/nein</c>, case-insensitive).
/// </para>
/// <para>
/// <b>UDT placeholders</b>: rows whose <c>Data type</c> is <c>Struct</c> or names
/// a user-defined type (<c>"udt_name"</c> with the surrounding quotes that TIA
/// emits) import as a non-functional placeholder tag — the result still lands in
/// the driver options so it shows up in the Admin UI tag list, but the data type
/// is forced to <see cref="S7DataType.Byte"/> and the comment carries the
/// marker <c>[UDT placeholder — wait for D2]</c>. <see cref="S7ImportResult.UdtPlaceholderCount"/>
/// tracks how many of the imported tags fell into this bucket.
/// </para>
/// <para>
/// <b>HMI-accessible filter</b>: rows whose <c>Hmi accessible</c> column is
/// explicitly <c>false</c> / <c>FALSCH</c> / <c>nein</c> are skipped — these are
/// internal symbols TIA shows in the editor but doesn't expose to client
/// interfaces. Missing or blank <c>Hmi accessible</c> defaults to <c>true</c>
/// (older TIA Portal versions don't emit the column at all).
/// </para>
/// </remarks>
public sealed class TiaCsvImporter : IS7SymbolImporter
{
private readonly ILogger<TiaCsvImporter> _logger;
public TiaCsvImporter() : this(NullLogger<TiaCsvImporter>.Instance) { }
public TiaCsvImporter(ILogger<TiaCsvImporter> logger)
{
_logger = logger ?? NullLogger<TiaCsvImporter>.Instance;
}
/// <inheritdoc />
public S7ImportResult Parse(Stream stream, S7ImportOptions? options = null)
{
ArgumentNullException.ThrowIfNull(stream);
var opts = options ?? new S7ImportOptions();
var tags = new List<S7TagDefinition>();
var parsed = 0;
var skipped = 0;
var errors = 0;
var udtPlaceholders = 0;
using var reader = new StreamReader(stream, Encoding.UTF8,
detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
int? nameIdx = null;
int? addressIdx = null;
int? dataTypeIdx = null;
int? commentIdx = null;
int? hmiAccessibleIdx = null;
int? lengthIdx = null;
var headerSeen = false;
var deLocale = false;
var lineNumber = 0;
// Sniff: TIA emits ';' as the field separator under DE locale (because comma is
// the decimal separator). en-US uses ','. Detect from the first non-blank line by
// counting which separator yields more fields when we tokenise the header.
var allLines = new List<string>();
string? line;
while ((line = reader.ReadLine()) is not null)
{
allLines.Add(line);
}
char separator = DetectSeparator(allLines);
foreach (var rawLine in allLines)
{
lineNumber++;
if (string.IsNullOrWhiteSpace(rawLine)) continue;
var trimmed = rawLine.TrimStart();
// Comment-only safety net — most TIA exports don't carry comments, but
// operators sometimes annotate fixtures by hand. Treat lines starting
// with `#` as comments. `;` is ambiguous (it's the DE-locale separator)
// so we explicitly do NOT treat it as a comment marker.
if (trimmed.StartsWith('#'))
{
continue;
}
var fields = SplitCsv(rawLine, separator);
if (fields.Count == 0) continue;
if (!headerSeen)
{
for (var i = 0; i < fields.Count; i++)
{
var header = fields[i].Trim().Trim('"').ToLowerInvariant();
switch (header)
{
case "name": nameIdx = i; break;
case "logical address":
case "address": addressIdx = i; break;
case "data type":
case "datatype":
case "type": dataTypeIdx = i; break;
case "comment": commentIdx = i; break;
case "hmi accessible":
case "hmi-accessible": hmiAccessibleIdx = i; break;
case "length": lengthIdx = i; break;
}
}
if (nameIdx is null || addressIdx is null)
{
throw new InvalidDataException(
$"TIA CSV header at line {lineNumber} is missing required Name or Logical address column. " +
$"Got: {string.Join(separator.ToString(), fields)}");
}
headerSeen = true;
continue;
}
// Locale sniff: examine the first data row's address column for a comma between
// digits, which only happens in DE locale (en-US uses '.' for the decimal point).
if (!deLocale && addressIdx is { } ai && fields.Count > ai)
{
var rawAddr = fields[ai].Trim().Trim('"');
if (LooksDeLocale(rawAddr))
{
deLocale = true;
_logger.LogInformation(
"TIA CSV locale auto-detected as DE (decimal-comma in address '{Address}' at line {LineNumber})",
rawAddr, lineNumber);
}
}
if (opts.MaxRowsToImport is int cap && parsed >= cap)
{
_logger.LogWarning(
"TIA CSV import hit MaxRowsToImport={Cap} at line {LineNumber}; remaining rows skipped.",
cap, lineNumber);
break;
}
try
{
var name = SafeField(fields, nameIdx!.Value).Trim().Trim('"');
var address = SafeField(fields, addressIdx!.Value).Trim().Trim('"');
var dataTypeStr = dataTypeIdx.HasValue ? SafeField(fields, dataTypeIdx.Value).Trim().Trim('"') : string.Empty;
var comment = commentIdx.HasValue ? SafeField(fields, commentIdx.Value).Trim().Trim('"') : null;
var hmiAccessible = hmiAccessibleIdx.HasValue
? ParseBoolColumn(SafeField(fields, hmiAccessibleIdx.Value), defaultValue: true)
: true;
var lengthStr = lengthIdx.HasValue ? SafeField(fields, lengthIdx.Value).Trim().Trim('"') : null;
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(address))
{
skipped++;
_logger.LogWarning(
"TIA CSV row at line {LineNumber} skipped — missing Name or Logical address (name='{Name}', address='{Address}').",
lineNumber, name, address);
continue;
}
if (!hmiAccessible)
{
skipped++;
continue;
}
// Strip the leading '%' that TIA always emits and rewrite DE-locale comma
// to '.' so the rest of the pipeline sees a single canonical address shape.
var normalised = NormaliseAddress(address, deLocale);
// Detect UDT placeholder before the strict address parser runs. UDTs in TIA
// typically address as DBn (whole DB) and don't carry a width suffix; the
// address parser would reject them.
var isUdtPlaceholder = IsUdtTypeName(dataTypeStr);
if (isUdtPlaceholder)
{
var placeholder = new S7TagDefinition(
Name: name,
Address: normalised,
DataType: S7DataType.Byte,
Writable: false);
tags.Add(placeholder);
udtPlaceholders++;
parsed++;
_logger.LogInformation(
"TIA CSV row at line {LineNumber} imported as UDT placeholder (Name='{Name}', DataType='{DataType}'). [UDT placeholder — wait for D2]",
lineNumber, name, dataTypeStr);
continue;
}
if (!TryResolveDataType(dataTypeStr, normalised, out var s7Type))
{
if (!opts.IgnoreInvalid)
{
throw new InvalidDataException(
$"TIA CSV row at line {lineNumber} has unrecognised Data type '{dataTypeStr}' for address '{address}'.");
}
errors++;
_logger.LogWarning(
"TIA CSV row at line {LineNumber} skipped — unrecognised Data type '{DataType}' for address '{Address}'.",
lineNumber, dataTypeStr, address);
continue;
}
// Validate the address syntax — strict so a typo in TIA's "Show all tags"
// export surfaces at import time, not as a misleading runtime read failure.
if (!S7AddressParser.TryParse(normalised, out _))
{
if (!opts.IgnoreInvalid)
{
throw new InvalidDataException(
$"TIA CSV row at line {lineNumber} has invalid S7 address '{normalised}'.");
}
errors++;
_logger.LogWarning(
"TIA CSV row at line {LineNumber} skipped — invalid S7 address '{Address}'.",
lineNumber, normalised);
continue;
}
var stringLength = 254;
if (s7Type == S7DataType.String && !string.IsNullOrWhiteSpace(lengthStr) &&
int.TryParse(lengthStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var len) &&
len > 0)
{
stringLength = len;
}
_ = comment; // currently parsed but unused — S7TagDefinition has no Comment field today.
tags.Add(new S7TagDefinition(
Name: name,
Address: normalised,
DataType: s7Type,
Writable: true,
StringLength: stringLength));
parsed++;
}
catch (InvalidDataException) when (opts.IgnoreInvalid)
{
errors++;
_logger.LogWarning("TIA CSV row at line {LineNumber} skipped — invalid data.", lineNumber);
}
catch (Exception ex) when (opts.IgnoreInvalid)
{
errors++;
_logger.LogWarning(ex, "TIA CSV row at line {LineNumber} skipped — parser threw.", lineNumber);
}
}
if (!headerSeen)
{
return new S7ImportResult([], 0, skipped, errors, udtPlaceholders);
}
return new S7ImportResult(tags, parsed, skipped, errors, udtPlaceholders);
}
/// <summary>
/// Detect the field separator a TIA CSV uses. en-US locale uses ','; DE locale
/// uses ';' (because ',' is the decimal separator). We sniff by counting the
/// occurrences in the first non-blank line — whichever character appears more
/// wins, defaulting to ',' for ties or empty inputs.
/// </summary>
internal static char DetectSeparator(IReadOnlyList<string> lines)
{
foreach (var l in lines)
{
if (string.IsNullOrWhiteSpace(l)) continue;
var commas = 0;
var semicolons = 0;
var inQuotes = false;
foreach (var c in l)
{
if (c == '"') inQuotes = !inQuotes;
else if (!inQuotes && c == ',') commas++;
else if (!inQuotes && c == ';') semicolons++;
}
if (semicolons > commas) return ';';
return ',';
}
return ',';
}
/// <summary>
/// Heuristic: a TIA address column carrying <c>%MW0,5</c>, <c>%DB1.DBD0,3</c>, or
/// similar digit-comma-digit pattern indicates DE locale. en-US would have written
/// <c>%MW0.5</c> instead.
/// </summary>
internal static bool LooksDeLocale(string address)
{
if (string.IsNullOrEmpty(address)) return false;
for (var i = 1; i + 1 < address.Length; i++)
{
if (address[i] == ',' &&
address[i - 1] >= '0' && address[i - 1] <= '9' &&
address[i + 1] >= '0' && address[i + 1] <= '9')
{
return true;
}
}
return false;
}
/// <summary>
/// Strip the leading '%' that TIA always emits, and rewrite DE-locale ',' to '.'
/// when the importer has detected DE locale. The S7AddressParser only understands
/// en-US-style decimals, so we canonicalise here.
/// </summary>
internal static string NormaliseAddress(string address, bool deLocale)
{
var s = address.Trim();
if (s.StartsWith('%')) s = s.Substring(1);
if (deLocale) s = s.Replace(',', '.');
return s;
}
/// <summary>
/// Recognise a TIA <c>Data type</c> column value as a UDT type name. TIA emits
/// UDT references as the bare name (sometimes wrapped in quotes), and the literal
/// string <c>Struct</c> for inline anonymous structs. Standard primitive type
/// names (Bool, Byte, Int, Real, …) are excluded — anything else is treated as
/// a UDT reference and imports as a placeholder.
/// </summary>
/// <remarks>
/// The CSV splitter strips the surrounding quotes that TIA wraps UDT references
/// in, so by the time this method runs the value is bare (e.g. <c>CookerSettings</c>
/// rather than <c>"CookerSettings"</c>). We use the primitive-name allow-list as
/// the discriminator and treat any unrecognised non-empty string as a UDT.
/// </remarks>
internal static bool IsUdtTypeName(string dataType)
{
if (string.IsNullOrWhiteSpace(dataType)) return false;
var stripped = dataType.Trim().Trim('"');
if (string.IsNullOrEmpty(stripped)) return false;
if (string.Equals(stripped, "Struct", StringComparison.OrdinalIgnoreCase)) return true;
// Array of UDT — `Array[0..9] of "MyUdt"` — also placeholder-worthy. Array of
// primitive (`Array[0..9] of Int`) is array, also placeholder until D2 ships
// proper array-of-UDT layout (PR-S7-D2 territory).
if (stripped.StartsWith("Array", StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Anything that isn't a recognised primitive is treated as a UDT reference.
return !IsPrimitiveType(stripped);
}
private static bool IsPrimitiveType(string raw)
{
return raw.Trim().ToLowerInvariant() switch
{
"bool" or "byte" or "char" or "wchar" or "word" or "dword" or "lword" or
"int" or "uint" or "dint" or "udint" or "lint" or "ulint" or "sint" or "usint" or
"real" or "lreal" or
"string" or "wstring" or
"date" or "time" or "time_of_day" or "tod" or "date_and_time" or "dt" or
"dtl" or "s5time" => true,
_ => false,
};
}
/// <summary>
/// Resolve a TIA <c>Data type</c> column value (or, when blank, the address's
/// size suffix) to the matching <see cref="S7DataType"/>. Returns <c>false</c> for
/// unparsable types.
/// </summary>
public static bool TryResolveDataType(string dataType, string address, out S7DataType s7Type)
{
s7Type = S7DataType.Byte;
var dt = (dataType ?? string.Empty).Trim().Trim('"').ToLowerInvariant();
// Direct mapping for the TIA primitive type names.
var direct = dt switch
{
"bool" => S7DataType.Bool,
"byte" or "sint" or "usint" => S7DataType.Byte,
"int" => S7DataType.Int16,
"word" or "uint" => S7DataType.UInt16,
"dint" => S7DataType.Int32,
"dword" or "udint" => S7DataType.UInt32,
"lint" => S7DataType.Int64,
"lword" or "ulint" => S7DataType.UInt64,
"real" => S7DataType.Float32,
"lreal" => S7DataType.Float64,
"string" => S7DataType.String,
"wstring" => S7DataType.WString,
"char" => S7DataType.Char,
"wchar" => S7DataType.WChar,
"date" => S7DataType.Date,
"time" => S7DataType.Time,
"time_of_day" or "tod" => S7DataType.TimeOfDay,
"date_and_time" or "dt" => S7DataType.DateAndTime,
"dtl" => S7DataType.Dtl,
"s5time" => S7DataType.S5Time,
_ => (S7DataType?)null,
};
if (direct.HasValue)
{
s7Type = direct.Value;
return true;
}
// Fallback: derive from the address size suffix. TIA exports sometimes leave the
// Data type column blank when the address's size letter (B/W/D/X) already pins it.
if (S7AddressParser.TryParse(address, out var parsed))
{
s7Type = parsed.Size switch
{
S7Size.Bit => S7DataType.Bool,
S7Size.Byte => S7DataType.Byte,
S7Size.Word => S7DataType.UInt16,
S7Size.DWord => S7DataType.UInt32,
S7Size.LWord => S7DataType.UInt64,
_ => S7DataType.Byte,
};
return true;
}
return false;
}
/// <summary>
/// Parse a TIA boolean column. Recognises both en-US (true/false/yes/no) and
/// DE-locale (wahr/falsch/ja/nein) values, plus the bare integer 0/1 some older
/// export tools emit. Empty / blank → <paramref name="defaultValue"/>.
/// </summary>
internal static bool ParseBoolColumn(string raw, bool defaultValue)
{
var s = raw?.Trim().Trim('"').ToLowerInvariant() ?? string.Empty;
return s switch
{
"" => defaultValue,
"true" or "wahr" or "yes" or "ja" or "1" => true,
"false" or "falsch" or "no" or "nein" or "0" => false,
_ => defaultValue,
};
}
private static string SafeField(IReadOnlyList<string> fields, int idx) =>
idx >= 0 && idx < fields.Count ? fields[idx] : string.Empty;
/// <summary>
/// RFC 4180-ish CSV splitter that accepts either ',' or ';' as the field
/// separator. Quoted fields, doubled-quote escape, embedded separators inside
/// quoted fields. Lifted from the AbLegacy importer pattern.
/// </summary>
internal static List<string> SplitCsv(string line, char separator)
{
var fields = new List<string>();
var sb = new StringBuilder(line.Length);
var inQuotes = false;
for (var i = 0; i < line.Length; i++)
{
var c = line[i];
if (inQuotes)
{
if (c == '"')
{
if (i + 1 < line.Length && line[i + 1] == '"')
{
sb.Append('"');
i++;
}
else
{
inQuotes = false;
}
}
else
{
sb.Append(c);
}
}
else
{
if (c == '"') inQuotes = true;
else if (c == separator)
{
fields.Add(sb.ToString());
sb.Clear();
}
else
{
sb.Append(c);
}
}
}
fields.Add(sb.ToString());
return fields;
}
}

View File

@@ -19,6 +19,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="S7netplus" Version="0.20.0"/> <PackageReference Include="S7netplus" Version="0.20.0"/>
<!-- PR-S7-D1 / #299 — TiaCsvImporter + AwlImporter log skipped/malformed rows
via ILogger so import-time issues surface in Serilog without making the
importer throw. Abstractions only — runtime sink is the host's responsibility. -->
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,218 @@
using System.IO;
using System.Text.Json;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests;
/// <summary>
/// Coverage for the <c>import-symbols</c> CLI command. The command is intentionally
/// thin (open file, hand to <c>TiaCsvImporter</c> / <c>AwlImporter</c>, serialise) —
/// these tests focus on the I/O + flag-handling shape rather than re-running the
/// parser.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ImportSymbolsCommandTests
{
private const string CanonicalTiaCsv = """
Name,Path,Data type,Logical address,Comment,Hmi accessible
MotorSpeed,Default,Int,%MW0,Motor speed,True
TankLevel,Default,Real,%MD4,Tank level,True
RunFlag,Default,Bool,%M0.0,Run flag,True
""";
private const string CanonicalAwl = """
VAR_GLOBAL
Speed : INT;
Pressure : REAL;
END_VAR
""";
[Fact]
public async Task Execute_with_tia_csv_emits_json_fragment_with_three_tags()
{
var path = Path.Combine(Path.GetTempPath(), $"s7-tia-{Guid.NewGuid():N}.csv");
File.WriteAllText(path, CanonicalTiaCsv);
try
{
using var console = new FakeInMemoryConsole();
var cmd = new ImportSymbolsCommand
{
File = path,
Format = "tia",
Emit = "appsettings-fragment",
};
await cmd.ExecuteAsync(console);
var output = console.ReadOutputString();
output.ShouldContain("\"Tags\"");
using var doc = JsonDocument.Parse(output);
var tags = doc.RootElement.GetProperty("Tags");
tags.GetArrayLength().ShouldBe(3);
tags[0].GetProperty("Name").GetString().ShouldBe("MotorSpeed");
tags[0].GetProperty("Address").GetString().ShouldBe("MW0");
tags[0].GetProperty("DataType").GetString().ShouldBe("Int16");
tags[2].GetProperty("DataType").GetString().ShouldBe("Bool");
}
finally
{
try { File.Delete(path); } catch { /* best-effort */ }
}
}
[Fact]
public async Task Execute_with_summary_emit_prints_counters()
{
var path = Path.Combine(Path.GetTempPath(), $"s7-tia-{Guid.NewGuid():N}.csv");
File.WriteAllText(path, CanonicalTiaCsv);
try
{
using var console = new FakeInMemoryConsole();
var cmd = new ImportSymbolsCommand
{
File = path,
Format = "tia",
Emit = "summary",
};
await cmd.ExecuteAsync(console);
var output = console.ReadOutputString();
output.ShouldContain("Imported 3");
output.ShouldContain("udt-placeholders 0");
}
finally
{
try { File.Delete(path); } catch { /* best-effort */ }
}
}
[Fact]
public async Task Execute_with_awl_format_emits_var_global_tags()
{
var path = Path.Combine(Path.GetTempPath(), $"s7-awl-{Guid.NewGuid():N}.awl");
File.WriteAllText(path, CanonicalAwl);
try
{
using var console = new FakeInMemoryConsole();
var cmd = new ImportSymbolsCommand
{
File = path,
Format = "awl",
Emit = "appsettings-fragment",
};
await cmd.ExecuteAsync(console);
var output = console.ReadOutputString();
using var doc = JsonDocument.Parse(output);
var tags = doc.RootElement.GetProperty("Tags");
tags.GetArrayLength().ShouldBe(2);
tags[0].GetProperty("Address").GetString().ShouldBe("MW0");
tags[1].GetProperty("Address").GetString().ShouldBe("MD2");
}
finally
{
try { File.Delete(path); } catch { /* best-effort */ }
}
}
[Fact]
public async Task Execute_with_output_path_writes_file_and_prints_summary()
{
var inputPath = Path.Combine(Path.GetTempPath(), $"s7-tia-{Guid.NewGuid():N}.csv");
var outputPath = Path.Combine(Path.GetTempPath(), $"s7-tia-{Guid.NewGuid():N}.json");
File.WriteAllText(inputPath, CanonicalTiaCsv);
try
{
using var console = new FakeInMemoryConsole();
var cmd = new ImportSymbolsCommand
{
File = inputPath,
Format = "tia",
Emit = "appsettings-fragment",
Output = outputPath,
};
await cmd.ExecuteAsync(console);
File.Exists(outputPath).ShouldBeTrue();
var fileBody = File.ReadAllText(outputPath);
using var doc = JsonDocument.Parse(fileBody);
doc.RootElement.GetProperty("Tags").GetArrayLength().ShouldBe(3);
console.ReadOutputString().ShouldContain("Wrote 3");
}
finally
{
try { File.Delete(inputPath); } catch { /* best-effort */ }
try { File.Delete(outputPath); } catch { /* best-effort */ }
}
}
[Fact]
public async Task Execute_with_missing_file_throws_command_exception()
{
var missing = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.csv");
using var console = new FakeInMemoryConsole();
var cmd = new ImportSymbolsCommand
{
File = missing,
};
var ex = await Should.ThrowAsync<CommandException>(async () => await cmd.ExecuteAsync(console));
ex.ExitCode.ShouldBe(1);
}
[Fact]
public async Task Execute_with_unknown_format_throws_command_exception()
{
var path = Path.Combine(Path.GetTempPath(), $"s7-tia-{Guid.NewGuid():N}.csv");
File.WriteAllText(path, CanonicalTiaCsv);
try
{
using var console = new FakeInMemoryConsole();
var cmd = new ImportSymbolsCommand
{
File = path,
Format = "yaml",
};
var ex = await Should.ThrowAsync<CommandException>(async () => await cmd.ExecuteAsync(console));
ex.ExitCode.ShouldBe(2);
}
finally
{
try { File.Delete(path); } catch { /* best-effort */ }
}
}
[Fact]
public async Task Execute_with_unknown_emit_throws_command_exception()
{
var path = Path.Combine(Path.GetTempPath(), $"s7-tia-{Guid.NewGuid():N}.csv");
File.WriteAllText(path, CanonicalTiaCsv);
try
{
using var console = new FakeInMemoryConsole();
var cmd = new ImportSymbolsCommand
{
File = path,
Format = "tia",
Emit = "xml",
};
var ex = await Should.ThrowAsync<CommandException>(async () => await cmd.ExecuteAsync(console));
ex.ExitCode.ShouldBe(2);
}
finally
{
try { File.Delete(path); } catch { /* best-effort */ }
}
}
}

View File

@@ -0,0 +1,25 @@
(* Sample STEP 7 Classic AWL file — PR-S7-D1 / #299 fixture.
Carries a VAR_GLOBAL block (M-area sequential offsets) and a
DATA_BLOCK (DB1, sequential DBW/DBD offsets). Comments stripped
before declaration parsing so this preamble does not affect counts.
*)
VAR_GLOBAL
// M-area globals — assigned sequentially: MW0, MW2, MD4
Speed : INT; // motor speed setpoint
Pressure : INT; // pressure transducer
ActualValue : REAL;
END_VAR
DATA_BLOCK DB1
TITLE = 'Sample DB'
VERSION : 0.1
STRUCT
CycleCount : INT; (* runtime cycle counter *)
Setpoint : REAL := 50.0; (* setpoint with init *)
ActualValue : REAL;
RunFlag : BOOL;
Recipe : STRING[20];
END_STRUCT;
BEGIN
END_DATA_BLOCK

View File

@@ -0,0 +1,9 @@
Name,Path,Data type,Logical address,Comment,Hmi accessible,Hmi visible,Hmi writeable,Length
ProbeWord,Default tag table,UInt,%MW0,Probe word for liveness,True,True,True,
SmokeI16,Default tag table,Int,%DB1.DBW10,Signed 16-bit smoke tag,True,True,True,
SmokeI32,Default tag table,DInt,%DB1.DBD20,Signed 32-bit smoke tag,True,True,True,
SmokeF32,Default tag table,Real,%DB1.DBD30,32-bit float smoke tag,True,True,True,
SmokeBool,Default tag table,Bool,%DB1.DBX50.3,Boolean smoke tag,True,True,True,
RecipeName,Default tag table,String,%DB2.DBB0,Recipe name string,True,True,True,32
CookerCfg,Default tag table,"CookerSettings",%DB10.DBB0,UDT placeholder — wait for D2,True,True,True,
HiddenInternal,Default tag table,Int,%MW100,Internal symbol — should be filtered,False,False,False,
1 Name Path Data type Logical address Comment Hmi accessible Hmi visible Hmi writeable Length
2 ProbeWord Default tag table UInt %MW0 Probe word for liveness True True True
3 SmokeI16 Default tag table Int %DB1.DBW10 Signed 16-bit smoke tag True True True
4 SmokeI32 Default tag table DInt %DB1.DBD20 Signed 32-bit smoke tag True True True
5 SmokeF32 Default tag table Real %DB1.DBD30 32-bit float smoke tag True True True
6 SmokeBool Default tag table Bool %DB1.DBX50.3 Boolean smoke tag True True True
7 RecipeName Default tag table String %DB2.DBB0 Recipe name string True True True 32
8 CookerCfg Default tag table CookerSettings %DB10.DBB0 UDT placeholder — wait for D2 True True True
9 HiddenInternal Default tag table Int %MW100 Internal symbol — should be filtered False False False

View File

@@ -0,0 +1,6 @@
Name;Path;Data type;Logical address;Comment;Hmi accessible;Hmi visible;Hmi writeable;Length
ProbeWort;Standard-Variablentabelle;UInt;%MW0;Probe-Wort für Liveness;WAHR;WAHR;WAHR;
SmokeI16;Standard-Variablentabelle;Int;%DB1.DBW10;Vorzeichenbehaftete 16-bit;WAHR;WAHR;WAHR;
SmokeBool;Standard-Variablentabelle;Bool;%DB1.DBX50,3;Boolescher Smoke-Tag;WAHR;WAHR;WAHR;
SmokeReal;Standard-Variablentabelle;Real;%DB1.DBD30;32-bit float;WAHR;WAHR;WAHR;
VerstecktInternal;Standard-Variablentabelle;Int;%MW100;Internes Symbol — herausgefiltert;FALSCH;FALSCH;FALSCH;
1 Name Path Data type Logical address Comment Hmi accessible Hmi visible Hmi writeable Length
2 ProbeWort Standard-Variablentabelle UInt %MW0 Probe-Wort für Liveness WAHR WAHR WAHR
3 SmokeI16 Standard-Variablentabelle Int %DB1.DBW10 Vorzeichenbehaftete 16-bit WAHR WAHR WAHR
4 SmokeBool Standard-Variablentabelle Bool %DB1.DBX50,3 Boolescher Smoke-Tag WAHR WAHR WAHR
5 SmokeReal Standard-Variablentabelle Real %DB1.DBD30 32-bit float WAHR WAHR WAHR
6 VerstecktInternal Standard-Variablentabelle Int %MW100 Internes Symbol — herausgefiltert FALSCH FALSCH FALSCH

View File

@@ -0,0 +1,83 @@
using System.IO;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.SymbolImport;
/// <summary>
/// PR-S7-D1 / #299 — golden-fixture integration test. Loads the canonical TIA Portal
/// CSV export shipped under <c>Fixtures/sample_tia_export.csv</c>, materialises a
/// driver-options object via <c>AddTiaCsvImport</c>, then exercises the runtime read
/// path against the python-snap7 simulator.
/// </summary>
/// <remarks>
/// <para>
/// The fixture's address layout (<c>%MW0</c>, <c>%DB1.DBW10</c>, <c>%DB1.DBD20</c>,
/// <c>%DB1.DBD30</c>, <c>%DB1.DBX50.3</c>) is deliberately aligned with the seed
/// offsets baked into the snap7 S7-1500 profile (<see cref="S7_1500Profile"/>) so
/// a successful round-trip proves the importer's address normalisation lands at
/// exactly the offsets the simulator seeds — a regression in <c>%</c>-stripping or
/// decimal-comma rewriting surfaces here as a read-mismatch, not a flaky timeout.
/// </para>
/// <para>
/// The test is build-only by default (the assertions run only when the simulator
/// fixture reports <c>SkipReason is null</c>) — local dev invokes it with snap7
/// running; CI relies on the fixture's auto-skip when no simulator is reachable.
/// </para>
/// </remarks>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class TiaCsvImportIntegrationTests(Snap7ServerFixture sim)
{
private static string FixturePath(string name) =>
Path.Combine(AppContext.BaseDirectory, "Fixtures", name);
[Fact]
public async Task Driver_imports_csv_then_reads_seeded_tags()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
// Start with an empty options object pinned at the simulator's endpoint, then
// layer the fixture CSV's tags on top via AddTiaCsvImport. The merge keeps the
// endpoint config (Host, Port, CpuType) untouched and adds the imported tags.
var baseOptions = new S7DriverOptions
{
Host = sim.Host,
Port = sim.Port,
CpuType = global::S7.Net.CpuType.S71500,
Timeout = TimeSpan.FromSeconds(5),
Probe = new S7ProbeOptions { Enabled = false },
Tags = [],
};
var options = baseOptions.AddTiaCsvImport(FixturePath("sample_tia_export.csv"), out var importResult);
// Fixture has 8 rows: 6 importable + 1 UDT placeholder + 1 HMI-hidden (skipped).
importResult.ParsedCount.ShouldBe(7);
importResult.SkippedCount.ShouldBe(1);
importResult.UdtPlaceholderCount.ShouldBe(1);
options.Tags.Count.ShouldBe(7);
await using var drv = new S7Driver(options, driverInstanceId: "s7-tia-import");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// Read the seed-aligned tags by their browse-name references. ProbeWord at MW0
// doesn't have a snap7 seed (the simulator profile seeds DB1.DBW0 instead), so
// we focus on the DB-anchored tags whose offsets match the S7_1500Profile seeds.
var snapshots = await drv.ReadAsync(
["SmokeI16", "SmokeI32", "SmokeF32", "SmokeBool"],
TestContext.Current.CancellationToken);
snapshots.Count.ShouldBe(4);
foreach (var s in snapshots) s.StatusCode.ShouldBe(0u, "imported-then-read must succeed end-to-end");
Convert.ToInt32(snapshots[0].Value).ShouldBe((int)S7_1500Profile.SmokeI16SeedValue);
Convert.ToInt32(snapshots[1].Value).ShouldBe(S7_1500Profile.SmokeI32SeedValue);
Convert.ToSingle(snapshots[2].Value).ShouldBe(S7_1500Profile.SmokeF32SeedValue, tolerance: 0.0001f);
Convert.ToBoolean(snapshots[3].Value).ShouldBeTrue();
}
}

View File

@@ -25,6 +25,11 @@
<ItemGroup> <ItemGroup>
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/> <None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
<!-- PR-S7-D1 / #299 — TIA Portal CSV + STEP 7 Classic AWL fixtures used by the
symbol-import integration test. -->
<None Update="Fixtures\sample_tia_export.csv" CopyToOutputDirectory="PreserveNewest"/>
<None Update="Fixtures\sample_tia_export_de_locale.csv" CopyToOutputDirectory="PreserveNewest"/>
<None Update="Fixtures\sample_step7_classic.awl" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,222 @@
using System.IO;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.SymbolImport;
/// <summary>
/// Unit coverage for <see cref="AwlImporter"/>. The AWL grammar is best-effort
/// position-based; these tests pin the assignment rules so a regression in offset
/// accounting surfaces immediately.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AwlImporterTests
{
private static S7ImportResult ParseString(string awl, S7ImportOptions? opts = null)
{
var importer = new AwlImporter(NullLogger<AwlImporter>.Instance);
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(awl));
return importer.Parse(stream, opts);
}
[Fact]
public void Parse_var_global_three_ints_yields_sequential_MW_addresses()
{
const string awl = """
VAR_GLOBAL
Speed : INT;
Pressure : INT;
Level : INT;
END_VAR
""";
var result = ParseString(awl);
result.ParsedCount.ShouldBe(3);
result.Tags.Count.ShouldBe(3);
result.Tags[0].Name.ShouldBe("Speed");
result.Tags[0].Address.ShouldBe("MW0");
result.Tags[0].DataType.ShouldBe(S7DataType.Int16);
result.Tags[1].Name.ShouldBe("Pressure");
result.Tags[1].Address.ShouldBe("MW2");
result.Tags[2].Name.ShouldBe("Level");
result.Tags[2].Address.ShouldBe("MW4");
}
[Fact]
public void Parse_data_block_mixed_int_real_yields_db_prefixed_addresses()
{
const string awl = """
DATA_BLOCK DB1
STRUCT
CycleCount : INT;
Setpoint : REAL;
ActualValue : REAL;
END_STRUCT;
BEGIN
END_DATA_BLOCK
""";
var result = ParseString(awl);
result.ParsedCount.ShouldBe(3);
result.Tags[0].Name.ShouldBe("CycleCount");
result.Tags[0].Address.ShouldBe("DB1.DBW0");
result.Tags[0].DataType.ShouldBe(S7DataType.Int16);
result.Tags[1].Name.ShouldBe("Setpoint");
result.Tags[1].Address.ShouldBe("DB1.DBD2");
result.Tags[1].DataType.ShouldBe(S7DataType.Float32);
result.Tags[2].Name.ShouldBe("ActualValue");
result.Tags[2].Address.ShouldBe("DB1.DBD6");
result.Tags[2].DataType.ShouldBe(S7DataType.Float32);
}
[Fact]
public void Parse_strips_block_and_line_comments()
{
const string awl = """
(* Block comment ignored *)
VAR_GLOBAL
// Line comment — ignored
Counter : INT; // inline comment after the decl
(* another (* faux-nested *) block non-nesting matcher should still consume it *)
Velocity : REAL;
END_VAR
""";
var result = ParseString(awl);
// Block-comment regex is non-greedy; "another " up to first "*)" is one comment, the rest
// becomes part of the body but the regex extracts the two valid declarations cleanly.
result.ParsedCount.ShouldBeGreaterThanOrEqualTo(2);
result.Tags.ShouldContain(t => t.Name == "Counter" && t.DataType == S7DataType.Int16);
result.Tags.ShouldContain(t => t.Name == "Velocity" && t.DataType == S7DataType.Float32);
}
[Fact]
public void Parse_empty_body_returns_empty_result()
{
var result = ParseString(string.Empty);
result.ParsedCount.ShouldBe(0);
result.Tags.ShouldBeEmpty();
}
[Fact]
public void Parse_var_global_with_initial_values_strips_init_clause()
{
const string awl = """
VAR_GLOBAL
SetVal : INT := 42;
Threshold : REAL := 3.14;
END_VAR
""";
var result = ParseString(awl);
result.ParsedCount.ShouldBe(2);
result.Tags[0].DataType.ShouldBe(S7DataType.Int16);
result.Tags[1].DataType.ShouldBe(S7DataType.Float32);
}
[Fact]
public void Parse_udt_typed_decl_imports_as_placeholder()
{
const string awl = """
DATA_BLOCK DB5
STRUCT
Cfg : "MyConfig";
Flag : BOOL;
END_STRUCT;
BEGIN
END_DATA_BLOCK
""";
var result = ParseString(awl);
result.ParsedCount.ShouldBe(2);
result.UdtPlaceholderCount.ShouldBe(1);
var placeholder = result.Tags.First(t => t.Name == "Cfg");
placeholder.DataType.ShouldBe(S7DataType.Byte);
placeholder.Writable.ShouldBeFalse();
}
[Fact]
public void Parse_string_with_length_uses_n_plus_2_byte_layout()
{
const string awl = """
DATA_BLOCK DB7
STRUCT
FirstByte : BYTE;
Recipe : STRING[80];
Trailing : INT;
END_STRUCT;
BEGIN
END_DATA_BLOCK
""";
var result = ParseString(awl);
result.ParsedCount.ShouldBe(3);
// Recipe (STRING[80]) consumes 82 bytes starting at offset 1 (rounded to 2 = 2);
// trailing INT lands at 84.
var first = result.Tags.First(t => t.Name == "FirstByte");
first.Address.ShouldBe("DB7.DBB0");
var trailing = result.Tags.First(t => t.Name == "Trailing");
trailing.Address.ShouldBe("DB7.DBW84");
}
[Fact]
public void StripComments_removes_block_and_line_comments()
{
var input = "VAR_GLOBAL (* hi *)\nA : INT; // trailing\nEND_VAR";
var stripped = AwlImporter.StripComments(input);
stripped.ShouldNotContain("(*");
stripped.ShouldNotContain("*)");
stripped.ShouldNotContain("//");
stripped.ShouldContain("A : INT;");
}
[Fact]
public void ResolveType_maps_primitive_step7_types_correctly()
{
AwlImporter.ResolveType("INT").S7Type.ShouldBe(S7DataType.Int16);
AwlImporter.ResolveType("REAL").S7Type.ShouldBe(S7DataType.Float32);
AwlImporter.ResolveType("LREAL").S7Type.ShouldBe(S7DataType.Float64);
AwlImporter.ResolveType("BOOL").S7Type.ShouldBe(S7DataType.Bool);
AwlImporter.ResolveType("DINT").S7Type.ShouldBe(S7DataType.Int32);
AwlImporter.ResolveType("STRING[20]").S7Type.ShouldBe(S7DataType.String);
AwlImporter.ResolveType("STRING[20]").SizeBytes.ShouldBe(22);
AwlImporter.ResolveType("STRUCT").IsUdt.ShouldBeTrue();
AwlImporter.ResolveType("\"MyType\"").IsUdt.ShouldBeTrue();
}
[Fact]
public void AlignTo_rounds_word_offsets_up_to_word_boundary()
{
AwlImporter.AlignTo(0, 2).ShouldBe(0);
AwlImporter.AlignTo(1, 2).ShouldBe(2);
AwlImporter.AlignTo(3, 2).ShouldBe(4);
AwlImporter.AlignTo(2, 4).ShouldBe(2); // 2-byte alignment is the cap
AwlImporter.AlignTo(0, 1).ShouldBe(0); // bytes don't need alignment
}
[Fact]
public void ExtractDbNumber_pulls_db_number_from_header()
{
AwlImporter.ExtractDbNumber(" DB1").ShouldBe(1);
AwlImporter.ExtractDbNumber(" DB42 \"Name\"").ShouldBe(42);
AwlImporter.ExtractDbNumber("\"NameOnly\"").ShouldBeNull();
}
[Fact]
public void Parse_max_rows_caps_imports()
{
const string awl = """
VAR_GLOBAL
A : INT;
B : INT;
C : INT;
D : INT;
END_VAR
""";
var result = ParseString(awl, new S7ImportOptions(MaxRowsToImport: 2));
result.ParsedCount.ShouldBe(2);
}
}

View File

@@ -0,0 +1,95 @@
using System.IO;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.SymbolImport;
/// <summary>
/// Coverage for <c>S7DriverFactoryExtensions.AddTiaCsvImport</c> and
/// <c>AddAwlImport</c>. The extension methods are thin wrappers around the importers
/// so the surface area to test is the merge behaviour + result propagation.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7DriverFactoryAddImportTests
{
[Fact]
public void AddTiaCsvImport_concatenates_imported_tags_onto_existing_options()
{
var path = Path.Combine(Path.GetTempPath(), $"tia-{Guid.NewGuid():N}.csv");
File.WriteAllText(path, """
Name,Path,Data type,Logical address,Comment,Hmi accessible
Imported1,T,Int,%MW0,desc,True
Imported2,T,Real,%MD4,desc,True
""");
try
{
var existing = new S7TagDefinition("PreExisting", "MW10", S7DataType.Int16);
var options = new S7DriverOptions
{
Host = "192.168.1.10",
Tags = [existing],
};
var updated = options.AddTiaCsvImport(path, out var result);
result.ParsedCount.ShouldBe(2);
updated.Tags.Count.ShouldBe(3);
updated.Tags[0].Name.ShouldBe("PreExisting");
updated.Tags[1].Name.ShouldBe("Imported1");
updated.Tags[2].Name.ShouldBe("Imported2");
// Other options fields propagate untouched.
updated.Host.ShouldBe("192.168.1.10");
}
finally
{
try { File.Delete(path); } catch { /* best-effort */ }
}
}
[Fact]
public void AddTiaCsvImportWithResult_returns_options_and_result_tuple()
{
var path = Path.Combine(Path.GetTempPath(), $"tia-{Guid.NewGuid():N}.csv");
File.WriteAllText(path, """
Name,Path,Data type,Logical address,Comment,Hmi accessible
T,P,Int,%MW0,d,True
""");
try
{
var (updated, result) = new S7DriverOptions { Host = "h" }.AddTiaCsvImportWithResult(path);
updated.Tags.Count.ShouldBe(1);
result.ParsedCount.ShouldBe(1);
}
finally
{
try { File.Delete(path); } catch { /* best-effort */ }
}
}
[Fact]
public void AddAwlImport_appends_var_global_declarations()
{
var path = Path.Combine(Path.GetTempPath(), $"awl-{Guid.NewGuid():N}.awl");
File.WriteAllText(path, """
VAR_GLOBAL
Speed : INT;
Pressure : REAL;
END_VAR
""");
try
{
var options = new S7DriverOptions { Host = "h", Tags = [] };
var updated = options.AddAwlImport(path, out var result);
result.ParsedCount.ShouldBe(2);
updated.Tags.Count.ShouldBe(2);
updated.Tags[0].Address.ShouldBe("MW0");
updated.Tags[1].Address.ShouldBe("MD2");
}
finally
{
try { File.Delete(path); } catch { /* best-effort */ }
}
}
}

View File

@@ -0,0 +1,290 @@
using System.IO;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.SymbolImport;
/// <summary>
/// Unit coverage for <see cref="TiaCsvImporter"/>. Drives the parser through
/// synthesised in-memory streams; the golden-fixture path lives in the integration
/// test project (<c>Driver_imports_csv_then_reads_seeded_tags</c>).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TiaCsvImporterTests
{
private static S7ImportResult ParseString(string csv, S7ImportOptions? opts = null)
{
var importer = new TiaCsvImporter(NullLogger<TiaCsvImporter>.Instance);
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(csv));
return importer.Parse(stream, opts);
}
[Fact]
public void Parse_canonical_TIA_csv_yields_correctly_typed_tag()
{
// Minimal canonical row: Name=Speed, Logical address=%MW0, Data type=Int.
const string csv = """
Name,Path,Data type,Logical address,Comment,Hmi accessible,Hmi visible,Hmi writeable,Length
Speed,Default tag table,Int,%MW0,Motor speed,True,True,True,
""";
var result = ParseString(csv);
result.ParsedCount.ShouldBe(1);
result.SkippedCount.ShouldBe(0);
result.ErrorCount.ShouldBe(0);
result.UdtPlaceholderCount.ShouldBe(0);
result.Tags.Count.ShouldBe(1);
var t = result.Tags[0];
t.Name.ShouldBe("Speed");
t.Address.ShouldBe("MW0");
t.DataType.ShouldBe(S7DataType.Int16);
t.Writable.ShouldBeTrue();
}
[Fact]
public void Parse_DE_locale_csv_with_decimal_comma_imports_normalised_addresses()
{
// DE locale uses ';' as field separator and ',' as decimal separator. The Bool
// address `%M0,5` would be `%M0.5` in en-US — auto-detect should rewrite it.
const string csv = """
Name;Path;Data type;Logical address;Comment;Hmi accessible
ProbeBit;Standard-Variablentabelle;Bool;%M0,5;Probe-Bit;WAHR
""";
var result = ParseString(csv);
result.ParsedCount.ShouldBe(1);
result.Tags[0].Address.ShouldBe("M0.5");
result.Tags[0].DataType.ShouldBe(S7DataType.Bool);
}
[Fact]
public void Parse_skips_header_and_comment_lines()
{
// Comments (`#`) live both before and after the header — both forms must survive
// the parser without bumping the tag count. `;` is NOT a comment marker (it's
// the DE-locale separator).
const string csv = """
# top-level comment
Name,Path,Data type,Logical address,Comment,Hmi accessible
# mid-stream comment
Tag1,T,Int,%MW0,desc,True
Tag2,T,Real,%MD4,desc,True
""";
var result = ParseString(csv);
result.ParsedCount.ShouldBe(2);
result.Tags[0].Name.ShouldBe("Tag1");
result.Tags[1].Name.ShouldBe("Tag2");
result.Tags[1].DataType.ShouldBe(S7DataType.Float32);
}
[Fact]
public void Parse_udt_typed_row_imports_as_placeholder_and_increments_counter()
{
// TIA emits UDT references either as the bare quoted name ("MyUdt") or as the
// literal "Struct" for inline anonymous structs.
const string csv = """"
Name,Path,Data type,Logical address,Comment,Hmi accessible
CookerCfg,T,"CookerSettings",%DB10.DBB0,UDT instance,True
InlineStruct,T,Struct,%DB11.DBB0,Inline struct,True
"""";
var result = ParseString(csv);
result.ParsedCount.ShouldBe(2);
result.UdtPlaceholderCount.ShouldBe(2);
result.Tags.ShouldAllBe(t => t.DataType == S7DataType.Byte);
result.Tags.ShouldAllBe(t => !t.Writable);
}
[Fact]
public void Parse_hmi_accessible_false_skips_row()
{
const string csv = """
Name,Path,Data type,Logical address,Comment,Hmi accessible
Visible,T,Int,%MW0,visible,True
Hidden,T,Int,%MW2,hidden,False
""";
var result = ParseString(csv);
result.ParsedCount.ShouldBe(1);
result.SkippedCount.ShouldBe(1);
result.Tags[0].Name.ShouldBe("Visible");
}
[Fact]
public void Parse_hmi_accessible_de_locale_falsch_skips_row()
{
// DE-locale boolean column values: WAHR=true, FALSCH=false.
const string csv = """
Name;Path;Data type;Logical address;Comment;Hmi accessible
Visible;T;Int;%MW10;visible;WAHR
Hidden;T;Int;%MW12;hidden;FALSCH
""";
var result = ParseString(csv);
result.ParsedCount.ShouldBe(1);
result.SkippedCount.ShouldBe(1);
result.Tags[0].Name.ShouldBe("Visible");
}
[Fact]
public void Parse_empty_stream_returns_empty_result()
{
var result = ParseString(string.Empty);
result.ParsedCount.ShouldBe(0);
result.SkippedCount.ShouldBe(0);
result.ErrorCount.ShouldBe(0);
result.UdtPlaceholderCount.ShouldBe(0);
result.Tags.ShouldBeEmpty();
}
[Fact]
public void Parse_missing_required_column_throws()
{
// No address column at all — structural failure.
const string csv = """
Name,Path,Data type,Comment,Hmi accessible
T,P,Int,desc,True
""";
Should.Throw<InvalidDataException>(() => ParseString(csv));
}
[Fact]
public void Parse_db_word_address_imports_with_correct_type()
{
const string csv = """
Name,Path,Data type,Logical address,Comment,Hmi accessible
Setpoint,T,Real,%DB1.DBD4,Setpoint,True
""";
var result = ParseString(csv);
result.ParsedCount.ShouldBe(1);
result.Tags[0].Address.ShouldBe("DB1.DBD4");
result.Tags[0].DataType.ShouldBe(S7DataType.Float32);
}
[Fact]
public void Parse_string_with_length_column_propagates_string_length()
{
const string csv = """
Name,Path,Data type,Logical address,Comment,Hmi accessible,Hmi visible,Hmi writeable,Length
Recipe,T,String,%DB2.DBB0,Recipe name,True,True,True,80
""";
var result = ParseString(csv);
result.ParsedCount.ShouldBe(1);
result.Tags[0].DataType.ShouldBe(S7DataType.String);
result.Tags[0].StringLength.ShouldBe(80);
}
[Fact]
public void Parse_max_rows_caps_imports()
{
const string csv = """
Name,Path,Data type,Logical address,Comment,Hmi accessible
A,T,Int,%MW0,a,True
B,T,Int,%MW2,b,True
C,T,Int,%MW4,c,True
D,T,Int,%MW6,d,True
""";
var result = ParseString(csv, new S7ImportOptions(MaxRowsToImport: 2));
result.ParsedCount.ShouldBe(2);
result.Tags.Count.ShouldBe(2);
}
[Fact]
public void Parse_invalid_address_strict_throws()
{
const string csv = """
Name,Path,Data type,Logical address,Comment,Hmi accessible
Bad,T,Int,%XYZ123,broken,True
""";
Should.Throw<InvalidDataException>(
() => ParseString(csv, new S7ImportOptions(IgnoreInvalid: false)));
}
[Fact]
public void Parse_invalid_address_permissive_skips_with_log()
{
var collector = new ListLogger<TiaCsvImporter>();
const string csv = """
Name,Path,Data type,Logical address,Comment,Hmi accessible
Good,T,Int,%MW0,good,True
Bad,T,Int,%XYZ123,broken,True
""";
var importer = new TiaCsvImporter(collector);
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(csv));
var result = importer.Parse(stream);
result.ParsedCount.ShouldBe(1);
result.ErrorCount.ShouldBe(1);
collector.Messages.ShouldContain(m => m.Contains("Bad") || m.Contains("invalid"));
}
[Fact]
public void Parse_handles_utf8_bom()
{
// TIA Portal on Windows can emit UTF-8 with a BOM — make sure
// detectEncodingFromByteOrderMarks=true on the StreamReader strips it.
const string csv = "Name,Path,Data type,Logical address,Comment,Hmi accessible\nT,P,Int,%MW0,d,True\n";
var bom = Encoding.UTF8.GetPreamble();
var bytes = Encoding.UTF8.GetBytes(csv);
var withBom = new byte[bom.Length + bytes.Length];
bom.CopyTo(withBom, 0);
bytes.CopyTo(withBom, bom.Length);
var importer = new TiaCsvImporter(NullLogger<TiaCsvImporter>.Instance);
using var stream = new MemoryStream(withBom);
var result = importer.Parse(stream);
result.ParsedCount.ShouldBe(1);
result.Tags[0].Name.ShouldBe("T");
}
[Fact]
public void IsUdtTypeName_recognises_struct_and_quoted_names_but_not_primitives()
{
TiaCsvImporter.IsUdtTypeName("Struct").ShouldBeTrue();
TiaCsvImporter.IsUdtTypeName("\"MyUdt\"").ShouldBeTrue();
TiaCsvImporter.IsUdtTypeName("Int").ShouldBeFalse();
TiaCsvImporter.IsUdtTypeName("Real").ShouldBeFalse();
TiaCsvImporter.IsUdtTypeName("\"Int\"").ShouldBeFalse();
TiaCsvImporter.IsUdtTypeName("").ShouldBeFalse();
}
[Fact]
public void DetectSeparator_picks_semicolon_when_more_common()
{
// Header full of semicolons → DE locale. One value with a comma in a comment
// shouldn't flip the detection.
var lines = new[] { "Name;Path;Data type;Logical address" };
TiaCsvImporter.DetectSeparator(lines).ShouldBe(';');
}
[Fact]
public void DetectSeparator_picks_comma_for_us_locale()
{
var lines = new[] { "Name,Path,Data type,Logical address" };
TiaCsvImporter.DetectSeparator(lines).ShouldBe(',');
}
[Fact]
public void NormaliseAddress_strips_percent_and_rewrites_de_comma()
{
TiaCsvImporter.NormaliseAddress("%MW0", deLocale: false).ShouldBe("MW0");
TiaCsvImporter.NormaliseAddress("%M0,5", deLocale: true).ShouldBe("M0.5");
TiaCsvImporter.NormaliseAddress("M0.5", deLocale: false).ShouldBe("M0.5");
}
/// <summary>In-memory <see cref="ILogger{T}"/> for assertion on the warning channel.</summary>
private sealed class ListLogger<T> : ILogger<T>
{
public List<string> Messages { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
Messages.Add(formatter(state, exception));
}
}
}