diff --git a/scr/FSI.BT.IR.Plc.TimeSync.sln b/scr/FSI.BT.IR.Plc.TimeSync.sln new file mode 100644 index 0000000..5d5ab17 --- /dev/null +++ b/scr/FSI.BT.IR.Plc.TimeSync.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.9.34723.18 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FSI.BT.IR.Plc.TimeSync", "FSI.BT.IR.Plc.TimeSync\FSI.BT.IR.Plc.TimeSync.csproj", "{23DFF9D8-7A25-4465-865B-3D1834AF5725}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {23DFF9D8-7A25-4465-865B-3D1834AF5725}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {23DFF9D8-7A25-4465-865B-3D1834AF5725}.Debug|Any CPU.Build.0 = Debug|Any CPU + {23DFF9D8-7A25-4465-865B-3D1834AF5725}.Release|Any CPU.ActiveCfg = Release|Any CPU + {23DFF9D8-7A25-4465-865B-3D1834AF5725}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {539E3901-0BD7-4435-8501-3C27CC52E614} + EndGlobalSection +EndGlobal diff --git a/scr/FSI.BT.IR.Plc.TimeSync/FSI.BT.IR.Plc.TimeSync.csproj b/scr/FSI.BT.IR.Plc.TimeSync/FSI.BT.IR.Plc.TimeSync.csproj new file mode 100644 index 0000000..787de7b --- /dev/null +++ b/scr/FSI.BT.IR.Plc.TimeSync/FSI.BT.IR.Plc.TimeSync.csproj @@ -0,0 +1,37 @@ + + + + net8.0 + enable + enable + dotnet-FSI.BT.IR.Plc.TimeSync-965c3cf3-0f37-484a-865d-4762bb9fe30e + + + + + + + + + + + + + + + Never + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + diff --git a/scr/FSI.BT.IR.Plc.TimeSync/Program.cs b/scr/FSI.BT.IR.Plc.TimeSync/Program.cs new file mode 100644 index 0000000..e6b19f6 --- /dev/null +++ b/scr/FSI.BT.IR.Plc.TimeSync/Program.cs @@ -0,0 +1,10 @@ +using FSI.BT.IR.Plc.TimeSync; +using static FSI.BT.IR.Plc.TimeSync.Settings.Context; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Services.AddHostedService(); +builder.Services.AddSingleton(); + +using IHost host = builder.Build(); +host.Run(); \ No newline at end of file diff --git a/scr/FSI.BT.IR.Plc.TimeSync/Properties/launchSettings.json b/scr/FSI.BT.IR.Plc.TimeSync/Properties/launchSettings.json new file mode 100644 index 0000000..8f4a42c --- /dev/null +++ b/scr/FSI.BT.IR.Plc.TimeSync/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "FSI.BT.IR.Plc.TimeSync": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/scr/FSI.BT.IR.Plc.TimeSync/Settings/AppContext.cs b/scr/FSI.BT.IR.Plc.TimeSync/Settings/AppContext.cs new file mode 100644 index 0000000..35dcf83 --- /dev/null +++ b/scr/FSI.BT.IR.Plc.TimeSync/Settings/AppContext.cs @@ -0,0 +1,71 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using CommandLine; +using Microsoft.Extensions.Primitives; +using static FSI.BT.IR.Plc.TimeSync.Settings.Context; + +namespace FSI.BT.IR.Plc.TimeSync.Settings +{ + public class AppContext : ISettings + { + private Cfg _cfg; + private string[] _args; + + public AppContext() + { + _args = new string[0]; + LoadSettings(); + LoadCfg(); + } + + private void LoadSettings() + { + var values = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .Build(); + + Settings = values.Get(); + } + + private void LoadCfg() + { + var values = new ConfigurationBuilder() + .AddJsonFile("config.json", optional: true, reloadOnChange: true) + .Build(); + + Cfg = values.Get(); + + Action onChange = () => + { + Cfg = values.Get(); + }; + + ChangeToken.OnChange(() => values.GetReloadToken(), onChange); + } + + public Context.Settings Settings { get; set; } + + public Cfg Cfg + { + get { return _cfg; } + set + { + _cfg = value; + RaisePropertyChanged(); + } + } + + private void RaisePropertyChanged([CallerMemberName] string propertyName = null) + { + // Null means no subscribers to the event + var handler = PropertyChanged; + if (handler != null) + { + handler(this, new PropertyChangedEventArgs(propertyName)); + } + } + + public event PropertyChangedEventHandler PropertyChanged; + } +} + diff --git a/scr/FSI.BT.IR.Plc.TimeSync/Settings/ConsoleArgs.cs b/scr/FSI.BT.IR.Plc.TimeSync/Settings/ConsoleArgs.cs new file mode 100644 index 0000000..90f5616 --- /dev/null +++ b/scr/FSI.BT.IR.Plc.TimeSync/Settings/ConsoleArgs.cs @@ -0,0 +1,12 @@ +using CommandLine; + +namespace FSI.BT.IR.Plc.TimeSync.Settings +{ + public class ConsoleArgs + { + [Option('v', "version", Required = false, HelpText = "Version")] + public bool Version { get; set; } + + + } +} diff --git a/scr/FSI.BT.IR.Plc.TimeSync/Settings/Context.cs b/scr/FSI.BT.IR.Plc.TimeSync/Settings/Context.cs new file mode 100644 index 0000000..9d8b96a --- /dev/null +++ b/scr/FSI.BT.IR.Plc.TimeSync/Settings/Context.cs @@ -0,0 +1,239 @@ +using System.ComponentModel; + +namespace FSI.BT.IR.Plc.TimeSync.Settings +{ + /// + /// Datenbasis von config.json und appsettings.json + /// Einstellungen und Konfigurationsdaten + /// + public class Context + { + public interface ISettings : INotifyPropertyChanged + { + /// + /// Daten von appsettings.json + /// + public Settings Settings { get; set; } + + /// + /// Daten von config.json + /// + public Cfg Cfg { get; set; } + } + + /// + /// Daten von appsettings.json + /// + public record Settings + { + /// + /// Versions-Informationen + /// + public Version Version { get; set; } + + /// + /// Build-Informationen + /// + public Build Build { get; set; } + + /// + /// Logging-Einstellungen + /// + public Logging Logging { get; set; } + } + + /// + /// Versions-Informationen + /// + public record Version + { + /// + /// Haupt-Versionsnummer + /// + public uint Major { get; set; } = 0; + + /// + /// Unter-Versionsnummer + /// + public uint Minor { get; set; } = 0; + + /// + /// Patch/Hotfix + /// + public uint Patch { get; set; } = 0; + + /// + /// optinoale Versionsinformationen + /// + public string? Optional { get; set; } + + /// + /// gibt die Versions-Nummer zurück + /// + /// Versionsnummer + public override string ToString() + { + return Major.ToString() + "." + Minor.ToString() + "." + Patch.ToString() + ((Optional == string.Empty || Optional == null) ? "" : "-" + Optional); + } + } + + /// + /// Build-Informationen + /// + public record Build + { + /// + /// Ersteller + /// + public string Creator { get; set; } + + /// + /// Organisation + /// + public string Organization { get; set; } + + /// + /// Erstellungsjahr + /// + public int CreationYear { get; set; } + + /// + /// Beschreibung + /// + public string Description { get; set; } + } + + public class Logging + { + public Loglevel LogLevel { get; set; } + } + + /// + /// Logging-Einstellungen + /// + public class Loglevel + { + public string Default { get; set; } + + [ConfigurationKeyName("Microsoft.Hosting.Lifetime")] + public string MicrosoftHostingLifetime { get; set; } + } + + /// + /// Daten von config.json + /// + public record Cfg + { + + /// + /// Spsen, deren Zeit mit NTP-Server syncronsiert werden sollen + /// + public List Plcs { get; set; } + + /// + /// NTP-Server Adresse + /// + public string NtpServer { get; set; } + } + + /// + /// SPS-Daten + /// + public record Plc : IPlc + { + /// + /// Name + /// + public string Name { get; set; } + + /// + /// Beschreibung + /// + public string Description { get; set; } + + /// + /// IP-Adresse + /// + public string Adress { get; set; } + + /// + /// Rack-Nummer + /// + public int Rack { get; set; } + + /// + /// Slot-Nummer + /// + public int Slot { get; set; } + + /// + /// Update-Intervall, in der die Zeit überprüft werden soll. + /// + public int UpdateIntervall { get; set; } + + /// + /// Zeitdifferenz, ab der die SPS-Zeit angepasst werden soll. + /// + public int TimeDifference { get; set; } + + /// + /// Locale Zeit wird an die SPS gesendet - nicht UTC-Zeit + /// + public bool LocalTime { get; set; } + + /// + /// Soll SPS-Zeit synchronisiert werden + /// + public bool Enable { get; set; } + } + + public interface IPlc + { + /// + /// Name + /// + public string Name { get; set; } + + /// + /// Beschreibung + /// + public string Description { get; set; } + + /// + /// IP-Adresse + /// + public string Adress { get; set; } + + /// + /// Rack-Nummer + /// + public int Rack { get; set; } + + /// + /// Slot-Nummer + /// + public int Slot { get; set; } + + /// + /// Update-Intervall, in der die Zeit überprüft werden soll. + /// + public int UpdateIntervall { get; set; } + + /// + /// Zeitdifferenz, ab der die SPS-Zeit angepasst werden soll. + /// + public int TimeDifference { get; set; } + + /// + /// Locale Zeit wird an die SPS gesendet - nicht UTC-Zeit + /// + public bool LocalTime { get; set; } + + /// + /// Soll SPS-Zeit synchronisiert werden + /// + public bool Enable { get; set; } + } + } + +} diff --git a/scr/FSI.BT.IR.Plc.TimeSync/SyncPlcTime.cs b/scr/FSI.BT.IR.Plc.TimeSync/SyncPlcTime.cs new file mode 100644 index 0000000..37290a1 --- /dev/null +++ b/scr/FSI.BT.IR.Plc.TimeSync/SyncPlcTime.cs @@ -0,0 +1,164 @@ +using NLog; +using Sharp7; +using GuerrillaNtp; +using static FSI.BT.IR.Plc.TimeSync.Settings.Context; + +namespace FSI.BT.IR.Plc.TimeSync +{ + internal class SyncPlcTime : IPlc + { + #region Constants + + private const string DATE_TIME_FORMAT = "dd.MM.yyyy HH:mm:ss.fff"; // Zeitformat + + #endregion + + private Logger _log = LogManager.GetCurrentClassLogger(); // Nlog + + public SyncPlcTime(IPlc plc) + { + Name = plc.Name; + Description = plc.Description; + Adress = plc.Adress; + Rack = plc.Rack; + Slot = plc.Slot; + UpdateIntervall = plc.UpdateIntervall; + TimeDifference = plc.TimeDifference; + LocalTime = plc.LocalTime; + Enable = plc.Enable; + } + + /// + /// Name + /// + public string Name { get; set; } + + /// + /// Beschreibung + /// + public string Description { get; set; } + + /// + /// IP-Adresse + /// + public string Adress { get; set; } + + /// + /// Rack-Nummer + /// + public int Rack { get; set; } + + /// + /// Slot-Nummer + /// + public int Slot { get; set; } + + /// + /// Update-Intervall, in der die Zeit überprüft werden soll. + /// + public int UpdateIntervall { get; set; } + + /// + /// Zeitdifferenz, ab der die SPS-Zeit angepasst werden soll. + /// + public int TimeDifference { get; set; } + + /// + /// Locale Zeit wird an die SPS gesendet - nicht UTC-Zeit + /// + public bool LocalTime { get; set; } + + /// + /// Soll SPS-Zeit synchronisiert werden + /// + public bool Enable { get; set; } + + /// + /// NTP-Server Adresse + /// + public string NtpServer { get; set; } + + public async Task Snyc(CancellationToken cancellationToken) => + await Task.Run(async () => + { + var plc = new S7Client(); // SPS-Verbindung + var client = new NtpClient(NtpServer); // NTP-Client + NtpClock clock = client.Query(); // NTP-Client Uhrzeit + var plcDateTime = new DateTime(); // Uhrzeit SPS + DateTime ntpDateTime; // Uhrzeit NTP-Client + + while (!cancellationToken.IsCancellationRequested) + { + try + { + // Verbindung mit SPS-Aufbauen + var connectionRslt = plc.ConnectTo(Adress, Rack, Slot); + + // Verbindungsstatus überprüfen + if (connectionRslt == 0) // Verbindung i.O. + { + _log.Debug(Name + " Verbindung hergestellt."); + } + else // Verbindung n.i.O. + { + _log.Error(Name + " Verbindung nicht hergestellt."); + _log.Error(Name + " Fehler: " + plc.ErrorText(connectionRslt)); + await Task.Delay(UpdateIntervall, cancellationToken); // Warten bis zum nächsten Verbindungsversuch (Zeiten aus config.json) + continue; + } + } + catch (Exception ex) + { + _log.Error(Name + " " + ex.Message); + } + + plc.GetPlcDateTime(ref plcDateTime); // Uhrzeit aus SPS auslesen + _log.Debug(Name + " SPS Zeit (aktuell): " + plcDateTime.ToString(DATE_TIME_FORMAT)); + + if (LocalTime) // lokale Zeit/Ortszeit + { + _log.Debug(Name + " Ortszeit: " + clock.Now.ToString(DATE_TIME_FORMAT)); + ntpDateTime = clock.Now.DateTime; // lokale Zeit/Ortszeit von NTP-Server + } + else // UTC - Zeit + { + _log.Debug(Name + " UTC-Zeit: " + clock.UtcNow.ToString(DATE_TIME_FORMAT)); + ntpDateTime = clock.UtcNow.DateTime; // UTC-Zeit von NTP-Server + } + + // Zeitdiffernz zwischen SPS-Zeit und Zeit von NTP-Server berechnen + var timeSpan = Math.Abs((plcDateTime - ntpDateTime).TotalMilliseconds); + + if ( + TimeDifference > 0 // Zeitdifferenz aus Einstellungen > 0 ms + && timeSpan >= TimeDifference // Zeitdifferenz überprüfung + ) + { + _log.Debug(Name + " Zeitdifferenz " + timeSpan + " ms überschritten"); + var temp = plc.SetPlcDateTime(ntpDateTime); // Zeit an SPS senden + _log.Info(Name + " neue Zeit: " + ntpDateTime.ToString(DATE_TIME_FORMAT)); + } + else if (TimeDifference == 0) + { + var temp = plc.SetPlcDateTime(ntpDateTime); // Zeit an SPS senden + _log.Info(Name + " neue Zeit: " + ntpDateTime.ToString(DATE_TIME_FORMAT)); + } + + plc.Disconnect(); // Verbindung zur SPS trennen + _log.Debug(Name + " Verbindung getrennt."); + + _log.Debug(Name + " Start Task-Wartezeit"); + + try + { + await Task.Delay(UpdateIntervall, cancellationToken); // Warten bis zum nächsten Verbindungsversuch (Zeiten aus config.json) + } + catch (Exception ex) + { + _log.Error(Name + " " + ex.Message); + } + _log.Debug(Name + " Ende Task-Wartezeit"); + } + }, cancellationToken); + } +} diff --git a/scr/FSI.BT.IR.Plc.TimeSync/Worker.cs b/scr/FSI.BT.IR.Plc.TimeSync/Worker.cs new file mode 100644 index 0000000..ea93646 --- /dev/null +++ b/scr/FSI.BT.IR.Plc.TimeSync/Worker.cs @@ -0,0 +1,85 @@ +using NLog; +using static FSI.BT.IR.Plc.TimeSync.Settings.Context; + +namespace FSI.BT.IR.Plc.TimeSync +{ + public class Worker(ISettings settings) : BackgroundService + { + private Logger _log = LogManager.GetCurrentClassLogger(); + private List _taskList; + private CancellationTokenSource _tokenSource; + private CancellationToken _stoppingToken; + + /// + /// Standard Taks + /// + /// + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + + settings.PropertyChanged += Settings_PropertyChanged; // Event, bei Änderungen an der Config-Datei + + StartTasks(); // Tasks, die beim Start ausgeführt werden + + } + + /// + /// Event, bei Änderungen an der config.json. + /// + /// + /// + private void Settings_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + StopTasks(); + StartTasks(); + } + + /// + /// Tasks, die beim Start ausgeführt werden sollen. + /// + private void StartTasks() + { + + if (_taskList != null) + return; + + _taskList = new List(); + _tokenSource = new CancellationTokenSource(); + _stoppingToken = _tokenSource.Token; + + // Schleife über alle Spsen + foreach (var plc in settings.Cfg.Plcs) + { + if (plc.Enable) + { + var timeSync = new SyncPlcTime(plc); + timeSync.NtpServer = settings.Cfg.NtpServer; + timeSync.Snyc(_stoppingToken); + _taskList.Add(timeSync); + _log.Info(plc.Name + " Task gestartet."); + } + } + + } + + /// + /// alle Taks werden gestoppt. + /// + private async void StopTasks() + { + _tokenSource.Cancel(); + _stoppingToken = _tokenSource.Token; + + // Schleife über alle Spsen + foreach (var task in _taskList) + { + task.Snyc(_stoppingToken); + _log.Info(task.Name + " Task beendet."); + } + + _taskList?.Clear(); + _taskList = null; + } + } +} diff --git a/scr/FSI.BT.IR.Plc.TimeSync/appsettings.Development.json b/scr/FSI.BT.IR.Plc.TimeSync/appsettings.Development.json new file mode 100644 index 0000000..b2dcdb6 --- /dev/null +++ b/scr/FSI.BT.IR.Plc.TimeSync/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/scr/FSI.BT.IR.Plc.TimeSync/appsettings.json b/scr/FSI.BT.IR.Plc.TimeSync/appsettings.json new file mode 100644 index 0000000..6884de1 --- /dev/null +++ b/scr/FSI.BT.IR.Plc.TimeSync/appsettings.json @@ -0,0 +1,20 @@ +{ + "Version": { + "Major": 0, + "Minor": 0, + "Patch": 0, + "Optional": "alpha" + }, + "Build": { + "Creator": "Stephan Maier", + "Organization": "Fondium Singen GmbH", + "CreationYear": "2024", + "Description": "" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/scr/FSI.BT.IR.Plc.TimeSync/config.json b/scr/FSI.BT.IR.Plc.TimeSync/config.json new file mode 100644 index 0000000..040a7c8 --- /dev/null +++ b/scr/FSI.BT.IR.Plc.TimeSync/config.json @@ -0,0 +1,27 @@ +{ + "Plcs": [ + { + "Name": "PL1 FA", + "Description": "Beschreibung", + "Adress": "10.10.199.95", + "Rack": 0, + "Slot": 2, + "UpdateIntervall": 10000, + "TimeDifference": 10, + "LocalTime": false, + "Enable": true + }, + { + "Name": "PL1 FA 123", + "Description": "PL1 Formanlage", + "Adress": "10.10.199.95", + "Rack": 0, + "Slot": 2, + "UpdateIntervall": 60000, + "TimeDifference": 0, + "LocalTime": true, + "Enable": false + } + ], + "NtpServer": "10.10.199.41" +} diff --git a/scr/FSI.BT.IR.Plc.TimeSync/nlog.config b/scr/FSI.BT.IR.Plc.TimeSync/nlog.config new file mode 100644 index 0000000..f0c9006 --- /dev/null +++ b/scr/FSI.BT.IR.Plc.TimeSync/nlog.config @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file