Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs
T

639 lines
30 KiB
C#

using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
/// <summary>
/// Pre-deployment validation pipeline. Validates a FlattenedConfiguration for correctness
/// before deployment. Also available on-demand (same logic, no deployment trigger).
///
/// Validation checks:
/// 1. Flattening success (no empty configuration)
/// 2. No naming collisions
/// 3. Script compilation (via ScriptCompiler)
/// 4. Alarm trigger references exist (referenced attributes must be in the flattened config)
/// 5. Script trigger references exist (referenced attributes must be in the flattened config)
/// 6. Expression triggers — blank check, syntax check, and attribute-reference scan
/// 7. Connection binding completeness — every data-sourced attribute must have a binding,
/// and (on the deploy path) the bound connection must exist on the target site.
/// Severity is context-dependent: a non-blocking Warning at template design time
/// (bindings are set later) and a deploy-gating Error when enforced (M2.8 / #23).
/// 8. List-attribute type semantics (MV-5, via <see cref="SemanticValidator"/>):
/// element-type cardinality, default-value parseability, and trigger-operand
/// rejection for List attributes.
/// 9. Does NOT verify tag path resolution on devices
/// </summary>
public class ValidationService
{
private readonly SemanticValidator _semanticValidator;
private readonly ScriptCompiler _scriptCompiler;
/// <summary>
/// Initializes a new instance of the ValidationService with the specified dependencies.
/// </summary>
/// <param name="semanticValidator">The semantic validator for configuration validation.</param>
/// <param name="scriptCompiler">The script compiler for validating script code.</param>
public ValidationService(SemanticValidator semanticValidator, ScriptCompiler scriptCompiler)
{
_semanticValidator = semanticValidator;
_scriptCompiler = scriptCompiler;
}
/// <summary>
/// Convenience constructor that creates default dependencies.
/// </summary>
public ValidationService() : this(new SemanticValidator(), new ScriptCompiler())
{
}
/// <summary>
/// Runs the full validation pipeline on a flattened configuration.
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
/// <param name="sharedScripts">Optional list of shared scripts for validation context.</param>
/// <param name="alarmCapableConnectionNames">
/// Optional set of site data-connection names whose protocol resolves to an
/// alarm-capable adapter (see
/// <see cref="Commons.Interfaces.Protocol.AlarmCapableProtocols"/>). When supplied,
/// the semantic validator gates every native-alarm-source binding against it.
/// <c>null</c> skips the capability check (its absence makes the check inert).
/// </param>
/// <param name="enforceConnectionBindings">
/// M2.8 (#23): controls the severity of the connection-binding-completeness check.
/// <para>
/// <c>false</c> (default) — template DESIGN-TIME: a data-sourced attribute that is
/// not yet bound produces only a non-blocking <c>Warning</c>. Bindings are set later,
/// at instance/deploy time, so an unbound data-sourced template attribute is legitimate
/// here (see <see cref="ManagementService"/>'s ValidateTemplate path, which builds a
/// config straight from raw template members with no bindings).
/// </para>
/// <para>
/// <c>true</c> — DEPLOY path (<see cref="DeploymentManager"/>'s FlatteningPipeline):
/// an unbound data-sourced attribute becomes a deploy-gating <c>Error</c> (IsValid false),
/// and — when <paramref name="siteConnectionNames"/> is supplied — a binding pointing at a
/// connection that does not exist on the target site is also an <c>Error</c>.
/// </para>
/// </param>
/// <param name="siteConnectionNames">
/// M2.8 (#23): optional set of the data-connection names that actually exist on the
/// target site (computed by the deploy pipeline from the site's loaded connections,
/// mirroring <paramref name="alarmCapableConnectionNames"/>). When supplied (and
/// <paramref name="enforceConnectionBindings"/> is <c>true</c>), every bound
/// connection is checked against this set so a binding to a phantom/stale connection
/// is caught. <c>null</c> skips the "exists at site" half (it stays inert).
/// </param>
/// <returns>A merged <see cref="ValidationResult"/> aggregating all pipeline stage outcomes.</returns>
public ValidationResult Validate(
FlattenedConfiguration configuration,
IReadOnlyList<ResolvedScript>? sharedScripts = null,
IReadOnlySet<string>? alarmCapableConnectionNames = null,
bool enforceConnectionBindings = false,
IReadOnlySet<string>? siteConnectionNames = null)
{
ArgumentNullException.ThrowIfNull(configuration);
var results = new List<ValidationResult>
{
ValidateFlatteningSuccess(configuration),
ValidateNamingCollisions(configuration),
ValidateScriptCompilation(configuration),
ValidateAlarmTriggerReferences(configuration),
ValidateScriptTriggerReferences(configuration),
ValidateExpressionTriggers(configuration),
ValidateConnectionBindingCompleteness(configuration, enforceConnectionBindings, siteConnectionNames),
_semanticValidator.Validate(configuration, sharedScripts, alarmCapableConnectionNames)
};
return ValidationResult.Merge(results.ToArray());
}
/// <summary>
/// Validates that flattening produced a non-empty configuration.
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
/// <returns>A <see cref="ValidationResult"/> with errors or warnings if the configuration is empty or missing a name; otherwise success.</returns>
public static ValidationResult ValidateFlatteningSuccess(FlattenedConfiguration configuration)
{
var errors = new List<ValidationEntry>();
if (string.IsNullOrWhiteSpace(configuration.InstanceUniqueName))
errors.Add(ValidationEntry.Error(ValidationCategory.FlatteningFailure,
"Instance unique name is missing."));
if (configuration.Attributes.Count == 0 &&
configuration.Alarms.Count == 0 &&
configuration.Scripts.Count == 0)
{
return new ValidationResult
{
Warnings = [ValidationEntry.Warning(ValidationCategory.FlatteningFailure,
"Flattened configuration contains no attributes, alarms, or scripts.")]
};
}
return errors.Count > 0
? new ValidationResult { Errors = errors }
: ValidationResult.Success();
}
/// <summary>
/// Validates that there are no naming collisions across entity types.
/// Canonical names must be unique within their entity type (attributes, alarms, scripts).
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
/// <returns>A <see cref="ValidationResult"/> with errors for each duplicate canonical name, or success.</returns>
public static ValidationResult ValidateNamingCollisions(FlattenedConfiguration configuration)
{
var errors = new List<ValidationEntry>();
CheckDuplicates(configuration.Attributes, a => a.CanonicalName, "Attribute", errors);
CheckDuplicates(configuration.Alarms, a => a.CanonicalName, "Alarm", errors);
CheckDuplicates(configuration.Scripts, s => s.CanonicalName, "Script", errors);
return errors.Count > 0
? new ValidationResult { Errors = errors }
: ValidationResult.Success();
}
/// <summary>
/// Validates that all scripts compile successfully using the ScriptCompiler.
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
/// <returns>A <see cref="ValidationResult"/> with errors for each script that fails compilation.</returns>
public ValidationResult ValidateScriptCompilation(FlattenedConfiguration configuration)
{
var errors = new List<ValidationEntry>();
var warnings = new List<ValidationEntry>();
foreach (var script in configuration.Scripts)
{
var result = _scriptCompiler.TryCompile(script.Code, script.CanonicalName);
if (!result.IsSuccess)
{
errors.Add(ValidationEntry.Error(ValidationCategory.ScriptCompilation,
$"Script '{script.CanonicalName}' failed compilation: {result.Error}",
script.CanonicalName));
}
}
return new ValidationResult { Errors = errors, Warnings = warnings };
}
/// <summary>
/// Validates that alarm trigger configurations reference existing attributes.
/// Alarm trigger configs are JSON with an "attributeName" field referencing a canonical attribute name.
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
/// <returns>A <see cref="ValidationResult"/> with errors for any alarm whose trigger references a missing attribute.</returns>
public static ValidationResult ValidateAlarmTriggerReferences(FlattenedConfiguration configuration)
{
var errors = new List<ValidationEntry>();
var attributeNames = new HashSet<string>(
configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal);
foreach (var alarm in configuration.Alarms)
{
if (string.IsNullOrWhiteSpace(alarm.TriggerConfiguration))
continue;
var attrName = ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration);
if (attrName != null && !attributeNames.Contains(attrName))
{
errors.Add(ValidationEntry.Error(ValidationCategory.AlarmTriggerReference,
$"Alarm '{alarm.CanonicalName}' references attribute '{attrName}' which does not exist in the flattened configuration.",
alarm.CanonicalName));
}
}
return errors.Count > 0
? new ValidationResult { Errors = errors }
: ValidationResult.Success();
}
/// <summary>
/// Validates that script trigger configurations reference existing attributes.
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
/// <returns>A <see cref="ValidationResult"/> with errors for any script whose trigger references a missing attribute.</returns>
public static ValidationResult ValidateScriptTriggerReferences(FlattenedConfiguration configuration)
{
var errors = new List<ValidationEntry>();
var attributeNames = new HashSet<string>(
configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal);
foreach (var script in configuration.Scripts)
{
if (string.IsNullOrWhiteSpace(script.TriggerConfiguration))
continue;
var attrName = ExtractAttributeNameFromTriggerConfig(script.TriggerConfiguration);
if (attrName != null && !attributeNames.Contains(attrName))
{
errors.Add(ValidationEntry.Error(ValidationCategory.ScriptTriggerReference,
$"Script '{script.CanonicalName}' trigger references attribute '{attrName}' which does not exist in the flattened configuration.",
script.CanonicalName));
}
}
return errors.Count > 0
? new ValidationResult { Errors = errors }
: ValidationResult.Success();
}
/// <summary>
/// Validates Expression-trigger scripts and alarms before deployment.
///
/// For every script/alarm whose trigger type is "Expression" this performs three
/// checks against the <c>{ "expression": "..." }</c> trigger configuration:
/// <list type="bullet">
/// <item>Blank expression → warning (the trigger will never fire).</item>
/// <item>Syntax/compile check → error if the expression uses a forbidden API
/// or does not compile as a bare boolean expression. Delegates to the shared
/// authoritative analyzer (see <see cref="ScriptCompiler"/> and
/// <see cref="CheckExpressionSyntax"/>) — a real Roslyn compile against the
/// <see cref="TriggerCompileSurface"/>, not a string scan.</item>
/// <item>Attribute-reference scan → error for any <c>Attributes["X"]</c> literal
/// whose key is absent from the flattened configuration, mirroring
/// <see cref="ValidateScriptTriggerReferences"/> for the structured triggers.</item>
/// </list>
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
/// <returns>A <see cref="ValidationResult"/> with errors and warnings from all expression trigger checks.</returns>
public static ValidationResult ValidateExpressionTriggers(FlattenedConfiguration configuration)
{
var errors = new List<ValidationEntry>();
var warnings = new List<ValidationEntry>();
var attributeNames = new HashSet<string>(
configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal);
foreach (var script in configuration.Scripts)
{
if (!IsExpressionTrigger(script.TriggerType))
continue;
CheckExpressionTrigger(
ValidationCategory.ScriptTriggerReference, "script",
script.CanonicalName, script.TriggerConfiguration,
attributeNames, errors, warnings);
}
foreach (var alarm in configuration.Alarms)
{
if (!IsExpressionTrigger(alarm.TriggerType))
continue;
CheckExpressionTrigger(
ValidationCategory.AlarmTriggerReference, "alarm",
alarm.CanonicalName, alarm.TriggerConfiguration,
attributeNames, errors, warnings);
}
return new ValidationResult { Errors = errors, Warnings = warnings };
}
private static bool IsExpressionTrigger(string? triggerType) =>
string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Runs the blank / syntax / attribute-reference checks for a single
/// Expression-trigger entity and appends any findings to the shared lists.
/// </summary>
/// <param name="category">
/// The <see cref="ValidationCategory"/> to file every finding under
/// (<see cref="ValidationCategory.ScriptTriggerReference"/> for scripts,
/// <see cref="ValidationCategory.AlarmTriggerReference"/> for alarms). The same
/// category is used for blank, syntax, and attribute-reference findings so an
/// alarm's syntax error is not miscategorised as script compilation.
/// </param>
/// <param name="entityLabel">
/// Human-readable entity-type label (<c>"script"</c>/<c>"alarm"</c>) used in
/// message text only.
/// </param>
private static void CheckExpressionTrigger(
ValidationCategory category,
string entityLabel,
string entityName,
string? triggerConfigJson,
HashSet<string> attributeNames,
List<ValidationEntry> errors,
List<ValidationEntry> warnings)
{
var expression = ExtractExpressionFromTriggerConfig(triggerConfigJson);
if (string.IsNullOrWhiteSpace(expression))
{
warnings.Add(ValidationEntry.Warning(category,
$"The {entityLabel} '{entityName}' has an expression trigger with no expression; it will never fire.",
entityName));
return;
}
var syntaxError = CheckExpressionSyntax(expression);
if (syntaxError != null)
{
errors.Add(ValidationEntry.Error(category,
$"The {entityLabel} '{entityName}' expression trigger failed validation: {syntaxError}",
entityName));
}
foreach (var attrName in ExtractAttributeReferences(expression))
{
if (!attributeNames.Contains(attrName))
{
errors.Add(ValidationEntry.Error(category,
$"The {entityLabel} '{entityName}' expression trigger references attribute '{attrName}' which does not exist in the flattened configuration.",
entityName));
}
}
}
/// <summary>
/// Reads the "expression" string from a <c>{ "expression": "..." }</c> trigger
/// configuration. Returns <c>null</c> on malformed JSON or a missing key.
/// </summary>
/// <param name="triggerConfigJson">The trigger configuration JSON to parse.</param>
/// <returns>The expression string, or <c>null</c> if absent or the JSON is malformed.</returns>
internal static string? ExtractExpressionFromTriggerConfig(string? triggerConfigJson)
{
if (string.IsNullOrWhiteSpace(triggerConfigJson))
return null;
try
{
using var doc = JsonDocument.Parse(triggerConfigJson);
if (doc.RootElement.TryGetProperty("expression", out var prop)
&& prop.ValueKind == JsonValueKind.String)
{
return prop.GetString();
}
}
catch (JsonException)
{
// Not valid JSON — treated as a blank expression by the caller.
}
return null;
}
/// <summary>
/// Authoritative syntax/trust check for a trigger expression. Delegates to the
/// shared <see cref="ZB.MOM.WW.ScadaBridge.ScriptAnalysis"/> analyzer (same gate
/// as <see cref="ScriptCompiler"/>): a real forbidden-API verdict
/// (<see cref="ScriptTrustValidator.FindViolations(string, System.Collections.Generic.IEnumerable{Microsoft.CodeAnalysis.MetadataReference})"/>)
/// followed by a real CSharpScript compile of the bare boolean expression against
/// the <see cref="TriggerCompileSurface"/> globals. Returns an error message, or
/// <c>null</c> when the expression is clean and compiles.
/// </summary>
/// <param name="expression">The expression to check.</param>
/// <returns>A human-readable error message if the expression is invalid; <c>null</c> if well-formed.</returns>
internal static string? CheckExpressionSyntax(string expression)
{
// Authoritative forbidden-API verdict first.
var violations = ScriptTrustValidator.FindViolations(expression);
if (violations.Count > 0)
return $"uses forbidden API: {violations[0]}";
// Real compile of the bare boolean expression against the trigger globals.
var errors = RoslynScriptCompiler.Compile(expression, typeof(TriggerCompileSurface));
if (errors.Count > 0)
return $"is not a valid expression: {errors[0]}";
return null;
}
/// <summary>
/// Scans an expression for <c>Attributes["..."]</c> string-literal accessor keys.
/// Best-effort: only matches double-quoted literals (the form the editor emits)
/// and skips keys built dynamically.
/// </summary>
/// <param name="expression">The expression to scan for attribute references.</param>
/// <returns>The distinct attribute key strings found in self-attribute accessor positions.</returns>
internal static IEnumerable<string> ExtractAttributeReferences(string expression)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
const string marker = "Attributes[";
var index = 0;
while ((index = expression.IndexOf(marker, index, StringComparison.Ordinal)) >= 0)
{
// Only treat this as a self-attribute reference when it is not a member
// access. A bare `Attributes["X"]` resolves against the flattened
// configuration; `Children["Pump"].Attributes["X"]` and
// `Parent.Attributes["X"]` are member accesses (preceded by '.') whose
// dotted/composed canonical names cannot be checked against the flat
// self-attribute set — skip them rather than emit a false positive.
if (index > 0 && expression[index - 1] == '.')
{
index += marker.Length;
continue;
}
var cursor = index + marker.Length;
// Skip whitespace between '[' and the literal.
while (cursor < expression.Length && char.IsWhiteSpace(expression[cursor]))
cursor++;
if (cursor < expression.Length && expression[cursor] == '"')
{
var keyStart = cursor + 1;
var keyEnd = keyStart;
while (keyEnd < expression.Length && expression[keyEnd] != '"')
{
if (expression[keyEnd] == '\\') keyEnd++; // skip escaped char
keyEnd++;
}
if (keyEnd < expression.Length)
{
var key = expression.Substring(keyStart, keyEnd - keyStart);
if (key.Length > 0 && seen.Add(key))
yield return key;
}
}
index += marker.Length;
}
}
/// <summary>
/// Validates connection bindings on data-sourced attributes. Only DATA-SOURCED
/// attributes (<see cref="ResolvedAttribute.DataSourceReference"/> != <c>null</c>)
/// require a binding; static attributes are never flagged.
///
/// M2.8 (#23): the severity is context-dependent (see <paramref name="enforce"/>).
/// At template design time (<c>enforce == false</c>) an unbound data-sourced
/// attribute is legitimate (bindings are set later) so it is only a non-blocking
/// <c>Warning</c>. On the deploy path (<c>enforce == true</c>) an unbound
/// data-sourced attribute is a deploy-gating <c>Error</c>, and — when
/// <paramref name="siteConnectionNames"/> is supplied — a binding to a connection
/// that does not exist on the target site is also an <c>Error</c>.
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
/// <param name="enforce">
/// <c>true</c> on the deploy path (unbound → Error + "exists at site" check);
/// <c>false</c> at design time (unbound → Warning only). Defaults to <c>false</c>
/// so design-time validation stays non-blocking.
/// </param>
/// <param name="siteConnectionNames">
/// Optional set of data-connection names that actually exist on the target site.
/// When non-<c>null</c> and <paramref name="enforce"/> is <c>true</c>, every bound
/// connection name is checked against this set. <c>null</c> skips the "exists at
/// site" check.
/// </param>
/// <returns>A <see cref="ValidationResult"/> with the binding findings at the appropriate severity.</returns>
public static ValidationResult ValidateConnectionBindingCompleteness(
FlattenedConfiguration configuration,
bool enforce = false,
IReadOnlySet<string>? siteConnectionNames = null)
{
var errors = new List<ValidationEntry>();
var warnings = new List<ValidationEntry>();
foreach (var attr in configuration.Attributes)
{
// Only data-sourced attributes participate in binding validation.
if (attr.DataSourceReference == null)
continue;
if (attr.BoundDataConnectionId == null)
{
// Unbound data-sourced attribute. At deploy time this gates the
// deployment; at design time the binding is set later, so it is
// only advisory.
//
// NOTE: this branch fires for TWO distinct cases that are
// indistinguishable post-flattening:
// 1. The user genuinely never set a binding.
// 2. The user set a binding, but FlatteningService.ApplyConnectionBindings
// silently dropped it because the stored DataConnectionId no longer
// resolves to any loaded site DataConnection (i.e. the connection was
// deleted after the binding was created). In that case the flattener
// leaves BoundDataConnectionId == null, and the attribute falls into
// this same "unbound → Error" path.
// The error message covers both cases; no behavioral change is needed.
if (enforce)
{
errors.Add(ValidationEntry.Error(ValidationCategory.ConnectionBinding,
$"Attribute '{attr.CanonicalName}' has a data source reference but no connection binding.",
attr.CanonicalName));
}
else
{
warnings.Add(ValidationEntry.Warning(ValidationCategory.ConnectionBinding,
$"Attribute '{attr.CanonicalName}' has a data source reference but no connection binding.",
attr.CanonicalName));
}
// Skip the "exists at site" check below — it only applies to bound attributes.
continue;
}
// The attribute IS bound. On the deploy path, verify the bound connection
// actually exists on the target site (resolve against the site's connection
// set, not just name presence in the config). A binding pointing at a
// non-existent/stale site connection is a deploy-gating Error.
if (enforce && siteConnectionNames != null &&
attr.BoundDataConnectionName != null &&
!siteConnectionNames.Contains(attr.BoundDataConnectionName))
{
errors.Add(ValidationEntry.Error(ValidationCategory.ConnectionBinding,
$"Attribute '{attr.CanonicalName}' is bound to data connection '{attr.BoundDataConnectionName}' " +
"which does not exist on the target site.",
attr.CanonicalName));
}
}
return new ValidationResult { Errors = errors, Warnings = warnings };
}
private static void CheckDuplicates<T>(
IReadOnlyList<T> items,
Func<T, string> getName,
string entityType,
List<ValidationEntry> errors)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var item in items)
{
var name = getName(item);
if (!seen.Add(name))
{
errors.Add(ValidationEntry.Error(ValidationCategory.NamingCollision,
$"{entityType} naming collision: '{name}' appears more than once.",
name));
}
}
}
/// <summary>
/// Extracts the attribute name from a trigger configuration JSON.
/// </summary>
/// <param name="triggerConfigJson">The trigger configuration JSON to parse.</param>
/// <returns>The attribute name from the <c>attributeName</c> or legacy <c>attribute</c> key, or <c>null</c> if absent or malformed.</returns>
internal static string? ExtractAttributeNameFromTriggerConfig(string triggerConfigJson)
{
// Accept both keys to stay consistent with FlatteningService.PrefixTriggerAttribute,
// AlarmActor.ParseEvalConfig and AlarmTriggerConfigCodec. Old data may still use
// "attribute"; the UI codec writes the canonical "attributeName".
try
{
using var doc = JsonDocument.Parse(triggerConfigJson);
if (doc.RootElement.TryGetProperty("attributeName", out var prop))
return prop.GetString();
if (doc.RootElement.TryGetProperty("attribute", out var legacyProp))
return legacyProp.GetString();
}
catch (JsonException)
{
// Not valid JSON, ignore
}
return null;
}
/// <summary>
/// Extracts the four HiLo setpoints from a trigger configuration JSON.
/// Any unset (or non-numeric) setpoint comes back as <c>null</c>. Returns
/// all-nulls on malformed JSON — callers should treat that as "nothing to
/// validate" and let other checks surface the deeper problem.
/// </summary>
/// <param name="triggerConfigJson">The trigger configuration JSON to parse.</param>
/// <returns>A <see cref="HiLoSetpoints"/> record with the parsed setpoint values; any missing or non-numeric value is <c>null</c>.</returns>
internal static HiLoSetpoints ExtractHiLoSetpoints(string triggerConfigJson)
{
try
{
using var doc = JsonDocument.Parse(triggerConfigJson);
var root = doc.RootElement;
return new HiLoSetpoints(
LoLo: ReadDouble(root, "loLo"),
Lo: ReadDouble(root, "lo"),
Hi: ReadDouble(root, "hi"),
HiHi: ReadDouble(root, "hiHi"),
LoLoDeadband: ReadDouble(root, "loLoDeadband"),
LoDeadband: ReadDouble(root, "loDeadband"),
HiDeadband: ReadDouble(root, "hiDeadband"),
HiHiDeadband: ReadDouble(root, "hiHiDeadband"));
}
catch (JsonException)
{
return new HiLoSetpoints(null, null, null, null, null, null, null, null);
}
}
private static double? ReadDouble(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var p)) return null;
return p.ValueKind switch
{
JsonValueKind.Number => p.GetDouble(),
JsonValueKind.String when double.TryParse(p.GetString(),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var v) => v,
_ => null
};
}
}
internal readonly record struct HiLoSetpoints(
double? LoLo, double? Lo, double? Hi, double? HiHi,
double? LoLoDeadband = null, double? LoDeadband = null,
double? HiDeadband = null, double? HiHiDeadband = null);