Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 66 additions & 27 deletions AGENTS.md

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion ConfigFiles/connection/connection.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,3 @@ port: 5432
database: semiplot
user: semiplot
password: ""
poll_interval_ms: 1000
2 changes: 2 additions & 0 deletions ConfigFiles/opc/opc.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
host: 127.0.0.1
port: 49875
1 change: 1 addition & 0 deletions SemiPlot.slnx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Solution>
<Project Path="SemiPlot/SemiPlot.AppHost/SemiPlot.AppHost.csproj"/>
<Project Path="SemiPlot/SemiPlot.Core/SemiPlot.Core.csproj"/>
<Project Path="SemiPlot/SemiPlot.DataSource.OpcUa/SemiPlot.DataSource.OpcUa.csproj"/>
<Project Path="SemiPlot/SemiPlot.DataSource.Postgres/SemiPlot.DataSource.Postgres.csproj"/>
<Project Path="SemiPlot/SemiPlot.Tests.Integration/SemiPlot.Tests.Integration.csproj"/>
<Project Path="SemiPlot/SemiPlot.Tests.Unit/SemiPlot.Tests.Unit.csproj"/>
Expand Down
8 changes: 6 additions & 2 deletions SemiPlot/SemiPlot.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
const string ChangeSeconds = "0.5";
// A running Simple-Scada holds 49875, so the stand serves OPC UA on a port of its own.
const string OpcPort = "55875";
// The archive trails the OPC UA stream as the SCADA's buffered archive does, so the stand shows that span.
const string ArchiveLagSeconds = "60";

// AppHostDirectory is SemiPlot/SemiPlot.AppHost, so the tracked set sits two levels above it.
var trackedConfiguration = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "..", "ConfigFiles"));
Expand All @@ -46,15 +48,17 @@
"--connection", writerConnection,
"--admin-connection", adminConnection,
"--config-dir", configDirectory,
"--change-seconds", ChangeSeconds)
"--change-seconds", ChangeSeconds,
"--opc-port", OpcPort)
.WaitFor(bench);

builder.AddProject<Projects.SemiPlot_Tools_ArchiveSeeder>("writer")
.WithArgs(
"--connection", writerConnection,
"--follow", "1",
"--change-seconds", ChangeSeconds,
"--opc-port", OpcPort)
"--opc-port", OpcPort,
"--archive-lag", ArchiveLagSeconds)
.WaitForCompletion(converge);

builder.AddProject<Projects.SemiPlot_UI>("viewer")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ namespace SemiPlot.Core.Configuration;
public enum ConfigurationSectionName
{
App,
Connection
Connection,
Opc
}

public enum SectionProblem
Expand Down
96 changes: 96 additions & 0 deletions SemiPlot/SemiPlot.Core/Configuration/ConnectionFile.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using FluentResults;

using SemiPlot.Core.Data.Errors;

using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;

namespace SemiPlot.Core.Configuration;

/// <summary>The parse and the field checks every connection section loader shares.</summary>
public static class ConnectionFile
{
public const string HostKey = "host";

public const string PortKey = "port";

private static readonly IDeserializer _deserializer = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();

// The error carries no text of the exception: a parser message embeds the offending scalar, and a password
// is a scalar. The raw detail rides on CausedBy, where only the log reads it.
public static Result<TDto> Deserialize<TDto>(
ConfigurationSectionName section,
string sectionDirectory,
string content)
where TDto : class, new()
{
try
{
return Result.Ok(_deserializer.Deserialize<TDto?>(content) ?? new TDto());
}
catch (Exception exception)
{
var error = new ConnectionFileError(section, sectionDirectory, ConnectionFileProblem.Unparseable, []);

return Result.Fail<TDto>(error.CausedBy(new ExceptionalError(exception)));
}
}

/// <summary>
/// Every blank text and an absent port as one missing-field error, the host first and the port last; then the
/// port's range; then the host's IPv4 form.
/// </summary>
public static Result Validate(
ConfigurationSectionName section,
string sectionDirectory,
string? host,
int? port,
IReadOnlyList<(string Key, string? Value)> otherTexts)
{
var missing = new List<string>();

if (string.IsNullOrWhiteSpace(host))
{
missing.Add(HostKey);
}

foreach (var (key, value) in otherTexts)
{
if (string.IsNullOrWhiteSpace(value))
{
missing.Add(key);
}
}

if (port is null)
{
missing.Add(PortKey);
}

if (missing.Count > 0)
{
return Invalid(section, sectionDirectory, ConnectionFileProblem.MissingField, missing);
}

if (port is < NetworkAddress.LowestPort or > NetworkAddress.HighestPort)
{
return Invalid(section, sectionDirectory, ConnectionFileProblem.OutOfRange, [PortKey]);
}

return NetworkAddress.IsIPv4(host)
? Result.Ok()
: Invalid(section, sectionDirectory, ConnectionFileProblem.HostNotIPv4, [HostKey]);
}

private static Result Invalid(
ConfigurationSectionName section,
string sectionDirectory,
ConnectionFileProblem kind,
IReadOnlyList<string> fieldNames)
{
return Result.Fail(new ConnectionFileError(section, sectionDirectory, kind, fieldNames));
}
}
36 changes: 36 additions & 0 deletions SemiPlot/SemiPlot.Core/Configuration/NetworkAddress.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System.Globalization;

namespace SemiPlot.Core.Configuration;

/// <summary>The host and port rules every connection section shares.</summary>
public static class NetworkAddress
{
public const int LowestPort = 1;

public const int HighestPort = 65535;

private const int HighestOctet = 255;

/// <summary>
/// Four dot-separated decimal octets from 0 to 255 with no leading zero, so no shortened or octal form.
/// </summary>
public static bool IsIPv4(string? text)
{
if (text is null)
{
return false;
}

var octets = text.Split('.');

return octets.Length == 4 && octets.All(IsOctet);
}

private static bool IsOctet(string octet)
{
return octet.Length is >= 1 and <= 3
&& octet.All(char.IsAsciiDigit)
&& (octet.Length == 1 || octet[0] != '0')
&& int.Parse(octet, CultureInfo.InvariantCulture) <= HighestOctet;
}
}
22 changes: 17 additions & 5 deletions SemiPlot/SemiPlot.Core/Data/Errors/ConnectionFileError.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using FluentResults;

using SemiPlot.Core.Configuration;

namespace SemiPlot.Core.Data.Errors;

public enum ConnectionFileProblem
Expand All @@ -11,21 +13,31 @@ public enum ConnectionFileProblem
}

/// <summary>
/// The connection section could not be turned into settings. <see cref="Kind"/> is what the operator's
/// A connection section could not be turned into settings. <see cref="Kind"/> is what the operator's
/// remedy routes on; <see cref="Fields"/> names the offending keys, never the section's own values.
/// </summary>
public sealed class ConnectionFileError(string path, ConnectionFileProblem kind, IReadOnlyList<string> fields)
: Error(Describe(path, kind, fields))
public sealed class ConnectionFileError(
ConfigurationSectionName section,
string path,
ConnectionFileProblem kind,
IReadOnlyList<string> fields)
: Error(Describe(section, path, kind, fields))
{
public ConfigurationSectionName Section { get; } = section;

public string Path { get; } = path;

public ConnectionFileProblem Kind { get; } = kind;

public IReadOnlyList<string> Fields { get; } = fields;

private static string Describe(string path, ConnectionFileProblem kind, IReadOnlyList<string> fields)
private static string Describe(
ConfigurationSectionName section,
string path,
ConnectionFileProblem kind,
IReadOnlyList<string> fields)
{
var message = $"The connection folder '{path}' is invalid ({kind})";
var message = $"The {section} configuration folder '{path}' is invalid ({kind})";
return fields.Count > 0 ? $"{message}; fields: {string.Join(", ", fields)}" : message;
}
}
69 changes: 69 additions & 0 deletions SemiPlot/SemiPlot.Core/Data/Errors/LiveSourceError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using FluentResults;

namespace SemiPlot.Core.Data.Errors;

/// <summary>What went wrong with the live source, as the operator needs it routed: one remedy per member.</summary>
public enum LiveSourceFault
{
/// <summary>No session: the server does not answer, or the session it held was lost.</summary>
Unreachable,

/// <summary>The server answers but offers no endpoint without security and with anonymous access.</summary>
EndpointRefused,

/// <summary>The server answers but holds no project folder to read the pens from.</summary>
ProjectMissing,

/// <summary>The server does not know some pens, or serves a value that is not a number for them.</summary>
PensUnserved
}

/// <summary>
/// Every failure the live source can report, keyed by <see cref="Kind"/>; consumers route on <see cref="Kind"/> and
/// never on <see cref="Error.Message"/>.
/// </summary>
public sealed class LiveSourceError(
LiveSourceFault kind,
string endpoint,
IReadOnlyList<int>? unknownPenIds = null,
IReadOnlyList<int>? notNumericPenIds = null)
: Error(Describe(kind, endpoint, unknownPenIds ?? [], notNumericPenIds ?? []))
{
public LiveSourceFault Kind { get; } = kind;

public string Endpoint { get; } = endpoint;

public IReadOnlyList<int> UnknownPenIds { get; } = unknownPenIds ?? [];

public IReadOnlyList<int> NotNumericPenIds { get; } = notNumericPenIds ?? [];

private static string Describe(
LiveSourceFault kind,
string endpoint,
IReadOnlyList<int> unknownPenIds,
IReadOnlyList<int> notNumericPenIds)
{
return kind switch
{
LiveSourceFault.Unreachable => $"No OPC UA session to {endpoint}.",
LiveSourceFault.EndpointRefused =>
$"The OPC UA server at {endpoint} offers no endpoint with security None and anonymous access.",
LiveSourceFault.ProjectMissing => $"The OPC UA server at {endpoint} holds no project folder.",
LiveSourceFault.PensUnserved => $"The OPC UA server at {endpoint} does not serve every pen."
+ PenGroups(unknownPenIds, notNumericPenIds),
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};
}

private static string PenGroups(IReadOnlyList<int> unknownPenIds, IReadOnlyList<int> notNumericPenIds)
{
var unknown = unknownPenIds.Count > 0
? FormattableString.Invariant($" Unknown pen ids: {string.Join(", ", unknownPenIds)}.")
: "";
var notNumeric = notNumericPenIds.Count > 0
? FormattableString.Invariant($" Non-numeric pen ids: {string.Join(", ", notNumericPenIds)}.")
: "";

return unknown + notNumeric;
}
}
9 changes: 8 additions & 1 deletion SemiPlot/SemiPlot.Core/Data/ILiveSource.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
using SemiPlot.Core.Data.Errors;
using SemiPlot.Core.Trends;

namespace SemiPlot.Core.Data;

/// <summary>The live edge of a pen set (docs/architecture/data-integration.md#realtime).</summary>
/// <summary>The live edge of a pen set and its session (docs/architecture/data-integration.md#realtime).</summary>
public interface ILiveSource
{
IObservable<IReadOnlyList<Sample>> Subscribe(IReadOnlyList<int> penIds);

/// <summary>Hot, replays the current state to each subscriber, never faults or completes before disposal.</summary>
IObservable<LiveSourceState> States { get; }

/// <summary>Hot, one error per pen fault, never faults or completes before disposal.</summary>
IObservable<LiveSourceError> PenFaults { get; }
}
37 changes: 37 additions & 0 deletions SemiPlot/SemiPlot.Core/Data/LiveSourceState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using SemiPlot.Core.Data.Errors;

namespace SemiPlot.Core.Data;

/// <summary>Whether the live source's session is up, and the fault that keeps it down when one is known.</summary>
public sealed record LiveSourceState
{
public static readonly LiveSourceState Connecting = new(isConnected: false, fault: null);

public static readonly LiveSourceState Connected = new(isConnected: true, fault: null);

private LiveSourceState(bool isConnected, LiveSourceError? fault)
{
IsConnected = isConnected;
Fault = fault;
}

public bool IsConnected { get; }

public LiveSourceError? Fault { get; }

public static LiveSourceState FaultedBy(LiveSourceError fault)
{
return new LiveSourceState(isConnected: false, fault);
}

/// <summary>The state's name for a log line: <c>Connected</c>, <c>Connecting</c> or the fault's kind.</summary>
public override string ToString()
{
if (IsConnected)
{
return nameof(Connected);
}

return Fault?.Kind.ToString() ?? nameof(Connecting);
}
}
6 changes: 5 additions & 1 deletion SemiPlot/SemiPlot.Core/Trends/Sample.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
namespace SemiPlot.Core.Trends;

/// <summary>A null <see cref="Value"/> is a break for the pen from this moment until its next sample.</summary>
public sealed record Sample(int PenId, DateTime TimestampUtc, double? Value);
public sealed record Sample(int PenId, DateTime TimestampUtc, double? Value)
{
/// <summary>The time axis' smallest step: a break placed beside a known stamp sits this far from it.</summary>
public static readonly TimeSpan BreakStep = TimeSpan.FromMilliseconds(1);
}
Loading
Loading