fix(modbus): enforce region/type compatibility at the tag factory

P6: Coils/DiscreteInputs carry only Bool and register regions never Bool;
the factory (and so the deploy-gate Inspect and the AdminUI parse consult)
refuses a mismatch, and an absent dataType defaults per region (Bool for a
bit region). A HoldingRegisters+Bool tag used to throw out of the coalescing
planner and stall every tag of the subscription; the planner also skips any
incompatible pair defensively. The tag editor keeps the type compatible when
the region changes; the CLI refuses the pair up front. The array-write parity
test records the Coils divergence from DriverArrayWriteSupport (owned outside
this lane) as follow-up F1.

Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:19:28 -04:00
parent 620ecc41dd
commit f827b3a134
9 changed files with 191 additions and 4 deletions
@@ -57,6 +57,14 @@ public sealed class ReadCommand : ModbusCommandBase
ValidateEndpoint();
var ct = console.RegisterCancellationHandler();
// The driver's tag factory refuses an incompatible region/type pair (the tag would read
// BadNodeIdUnknown) — say why up front instead.
if (!ModbusTagDefinitionFactory.IsRegionTypeCompatible(Region, DataType))
throw new CliFx.Exceptions.CommandException(
Region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs
? $"Region '{Region}' only carries Bool values (--type Bool); got '{DataType}'."
: $"Region '{Region}' carries register types; use --type BitInRegister for a bit of a register, not '{DataType}'.");
var tagName = SynthesiseTagName(Region, Address, DataType);
var tag = new ModbusTagDefinition(
Name: tagName,
@@ -74,6 +74,9 @@ public sealed class WriteCommand : ModbusCommandBase
throw new CliFx.Exceptions.CommandException(
$"Region 'Coils' only supports boolean values (--type Bool). " +
$"Type '{DataType}' cannot represent a single-bit coil write.");
if (!ModbusTagDefinitionFactory.IsRegionTypeCompatible(Region, DataType))
throw new CliFx.Exceptions.CommandException(
$"Region '{Region}' carries register types; use --type BitInRegister for a bit of a register, not '{DataType}'.");
var tagName = ReadCommand.SynthesiseTagName(Region, Address, DataType);
var tag = new ModbusTagDefinition(
@@ -50,7 +50,13 @@ public static class ModbusTagDefinitionFactory
// Strict enum reads: a present-but-invalid (typo'd) enum rejects the tag
// (→ BadNodeIdUnknown) instead of silently defaulting to a wrong-width Good.
if (!TagConfigJson.TryReadEnumStrict(root, "region", ModbusRegion.HoldingRegisters, out var region)) return false;
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", ModbusDataType.Int16, out var dataType)) return false;
// An absent dataType defaults per region: a bit region (Coils / DiscreteInputs) only carries
// Bool, so defaulting it to Int16 would reject every bit tag authored without the key.
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DefaultDataType(region), out var dataType)) return false;
// Region/type compatibility: a bit region carries only Bool, a register region never Bool.
// A mismatch used to throw out of the coalescing planner (killing every tag's poll) or
// silently return a bool into an Int32 node.
if (!IsRegionTypeCompatible(region, dataType)) return false;
if (!TagConfigJson.TryReadEnumStrict(root, "byteOrder", ModbusByteOrder.BigEndian, out var byteOrder)) return false;
if (!TagConfigJson.TryReadEnumStrict(root, "stringByteOrder", ModbusStringByteOrder.HighByteFirst, out var stringByteOrder)) return false;
var bitIndex = (byte)ReadInt(root, "bitIndex");
@@ -94,6 +100,25 @@ public static class ModbusTagDefinitionFactory
catch (InvalidOperationException) { return false; }
}
/// <summary>The data type an authored tag gets when its <c>dataType</c> key is absent.</summary>
/// <param name="region">The tag's region.</param>
/// <returns><c>Bool</c> for Coils / DiscreteInputs, <c>Int16</c> for the register regions.</returns>
public static ModbusDataType DefaultDataType(ModbusRegion region)
=> region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs ? ModbusDataType.Bool : ModbusDataType.Int16;
/// <summary>
/// Region/type compatibility, the one rule every authoring path and the driver share: Coils and
/// DiscreteInputs carry only <c>Bool</c>; HoldingRegisters and InputRegisters carry every register
/// type (including <c>BitInRegister</c>) but never <c>Bool</c>.
/// </summary>
/// <param name="region">The tag's region.</param>
/// <param name="dataType">The tag's data type.</param>
/// <returns><see langword="true"/> when the driver can read the pair.</returns>
public static bool IsRegionTypeCompatible(ModbusRegion region, ModbusDataType dataType)
=> region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs
? dataType == ModbusDataType.Bool
: dataType != ModbusDataType.Bool;
/// <summary>
/// Inverse of <see cref="FromTagConfig"/>: serialises a typed definition back to the camelCase
/// <c>TagConfig</c> JSON the mapper reads. Used by authoring paths (the Client CLI) that construct a
@@ -247,7 +272,17 @@ public static class ModbusTagDefinitionFactory
// Type-dependent guards. The byte/ushort NARROWING is part of the rule, not an accident of the
// mapper: it reads `(byte)bitIndex` / `(ushort)stringLength`, so 256 is bit 0 and is ACCEPTED.
// Describing the un-narrowed value here would call a blob invalid that the driver serves.
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", ModbusDataType.Int16, out var dataType)) return;
var region = TagConfigJson.TryReadEnumStrict(root, "region", ModbusRegion.HoldingRegisters, out var r)
? r : ModbusRegion.HoldingRegisters;
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DefaultDataType(region), out var dataType)) return;
if (root.TryGetProperty("region", out _) && !IsRegionTypeCompatible(region, dataType))
{
warnings.Add(region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs
? $"Modbus region {region} only carries Bool tags; got {dataType} — the tag is rejected (BadNodeIdUnknown)."
: $"Modbus Bool tags only live in Coils / DiscreteInputs; a {region} tag must use a register type "
+ "(use BitInRegister for a bit of a register) — the tag is rejected (BadNodeIdUnknown).");
}
if (dataType == ModbusDataType.String && (ushort)ReadInt(root, "stringLength") < 1)
{
@@ -1186,6 +1186,9 @@ public sealed class ModbusDriver
{
if (!table.TryGetValue(fullReferences[i], out var tag)) continue;
if (tag.CoalesceProhibited) continue;
// Defensive: the factory refuses incompatible pairs, but the planner's register math must never
// throw for a tag that slipped through — one bad tag would otherwise fail the whole batch.
if (!ModbusTagDefinitionFactory.IsRegionTypeCompatible(tag.Region, tag.DataType)) continue;
if (tag.ArrayCount.HasValue) continue;
if (tag.Region is not (ModbusRegion.HoldingRegisters or ModbusRegion.InputRegisters)) continue;
if (tag.DataType is ModbusDataType.String or ModbusDataType.BitInRegister) continue;